// Copyright (c) Microsoft. All Rights Reserved. Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Collections.Immutable; using System.Composition; using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.ErrorReporting; using Microsoft.CodeAnalysis.Host.Mef; using Microsoft.CodeAnalysis.Notification; using Microsoft.CodeAnalysis.Packaging; using Microsoft.CodeAnalysis.Shared.Utilities; using Microsoft.CodeAnalysis.SymbolSearch; using Microsoft.VisualStudio.ComponentModelHost; using Microsoft.VisualStudio.Editor; using Microsoft.VisualStudio.LanguageServices.Implementation.ProjectSystem; using Microsoft.VisualStudio.LanguageServices.SymbolSearch; using Microsoft.VisualStudio.LanguageServices.Utilities; using Microsoft.VisualStudio.Shell.Interop; using NuGet.VisualStudio; using Roslyn.Utilities; using SVsServiceProvider = Microsoft.VisualStudio.Shell.SVsServiceProvider; namespace Microsoft.VisualStudio.LanguageServices.Packaging { /// /// Free threaded wrapper around the NuGet.VisualStudio STA package installer interfaces. /// We want to be able to make queries about packages from any thread. For example, the /// add-NuGet-reference feature wants to know what packages a project already has /// references to. NuGet.VisualStudio provides this information, but only in a COM STA /// manner. As we don't want our background work to bounce and block on the UI thread /// we have this helper class which queries the information on the UI thread and caches /// the data so it can be read from the background. /// [ExportWorkspaceService(typeof(IPackageInstallerService)), Shared] internal partial class PackageInstallerService : AbstractDelayStartedService, IPackageInstallerService, IVsSearchProviderCallback { private readonly object _gate = new object(); private readonly VisualStudioWorkspaceImpl _workspace; private readonly SVsServiceProvider _serviceProvider; private readonly IVsEditorAdaptersFactoryService _editorAdaptersFactoryService; // We refer to the package services through proxy types so that we can // delay loading their DLLs until we actually need them. private IPackageServicesProxy _packageServices; private CancellationTokenSource _tokenSource = new CancellationTokenSource(); // We keep track of what types of changes we've seen so we can then determine what to // refresh on the UI thread. If we hear about project changes, we only refresh that // project. If we hear about a solution level change, we'll refresh all projects. private bool _solutionChanged; private HashSet _changedProjects = new HashSet(); private readonly ConcurrentDictionary _projectToInstalledPackageAndVersion = new ConcurrentDictionary(); [ImportingConstructor] public PackageInstallerService( VisualStudioWorkspaceImpl workspace, SVsServiceProvider serviceProvider, IVsEditorAdaptersFactoryService editorAdaptersFactoryService) : base(workspace, SymbolSearchOptions.Enabled, SymbolSearchOptions.SuggestForTypesInReferenceAssemblies, SymbolSearchOptions.SuggestForTypesInNuGetPackages) { _workspace = workspace; _serviceProvider = serviceProvider; _editorAdaptersFactoryService = editorAdaptersFactoryService; } public ImmutableArray PackageSources { get; private set; } = ImmutableArray.Empty; public event EventHandler PackageSourcesChanged; private bool IsEnabled => _packageServices != null; bool IPackageInstallerService.IsEnabled(ProjectId projectId) { if (_packageServices == null) { return false; } if (_projectToInstalledPackageAndVersion.TryGetValue(projectId, out var state)) { return state.IsEnabled; } // If we haven't scanned the project yet, assume that we're available for it. return true; } protected override void EnableService() { // Our service has been enabled. Now load the VS package dlls. var componentModel = (IComponentModel)_serviceProvider.GetService(typeof(SComponentModel)); var packageInstallerServices = componentModel.GetExtensions().FirstOrDefault(); var packageInstaller = componentModel.GetExtensions().FirstOrDefault(); var packageUninstaller = componentModel.GetExtensions().FirstOrDefault(); var packageSourceProvider = componentModel.GetExtensions().FirstOrDefault(); if (packageInstallerServices == null || packageInstaller == null || packageUninstaller == null || packageSourceProvider == null) { return; } _packageServices = new PackageServicesProxy( packageInstallerServices, packageInstaller, packageUninstaller, packageSourceProvider); // Start listening to additional events workspace changes. _workspace.WorkspaceChanged += OnWorkspaceChanged; _packageServices.SourcesChanged += OnSourceProviderSourcesChanged; } protected override void StartWorking() { this.AssertIsForeground(); if (!this.IsEnabled) { return; } OnSourceProviderSourcesChanged(this, EventArgs.Empty); OnWorkspaceChanged(null, new WorkspaceChangeEventArgs( WorkspaceChangeKind.SolutionAdded, null, null)); } private void OnSourceProviderSourcesChanged(object sender, EventArgs e) { if (!this.IsForeground()) { this.InvokeBelowInputPriority(() => OnSourceProviderSourcesChanged(sender, e)); return; } this.AssertIsForeground(); PackageSources = _packageServices.GetSources(includeUnOfficial: true, includeDisabled: false) .Select(r => new PackageSource(r.Key, r.Value)) .ToImmutableArrayOrEmpty(); PackageSourcesChanged?.Invoke(this, EventArgs.Empty); } public bool TryInstallPackage( Workspace workspace, DocumentId documentId, string source, string packageName, string versionOpt, bool includePrerelease, CancellationToken cancellationToken) { this.AssertIsForeground(); // The 'workspace == _workspace' line is probably not necessary. However, we include // it just to make sure that someone isn't trying to install a package into a workspace // other than the VisualStudioWorkspace. if (workspace == _workspace && _workspace != null && _packageServices != null) { var projectId = documentId.ProjectId; var dte = (EnvDTE.DTE)_serviceProvider.GetService(typeof(SDTE)); var dteProject = _workspace.TryGetDTEProject(projectId); if (dteProject != null) { var description = string.Format(ServicesVSResources.Install_0, packageName); var undoManager = _editorAdaptersFactoryService.TryGetUndoManager( workspace, documentId, cancellationToken); return TryInstallAndAddUndoAction( source, packageName, versionOpt, includePrerelease, dte, dteProject, undoManager); } } return false; } private bool TryInstallPackage( string source, string packageName, string versionOpt, bool includePrerelease, EnvDTE.DTE dte, EnvDTE.Project dteProject) { try { if (!_packageServices.IsPackageInstalled(dteProject, packageName)) { dte.StatusBar.Text = string.Format(ServicesVSResources.Installing_0, packageName); if (versionOpt == null) { _packageServices.InstallLatestPackage( source, dteProject, packageName, includePrerelease, ignoreDependencies: false); } else { _packageServices.InstallPackage( source, dteProject, packageName, versionOpt, ignoreDependencies: false); } var installedVersion = GetInstalledVersion(packageName, dteProject); dte.StatusBar.Text = string.Format(ServicesVSResources.Installing_0_completed, GetStatusBarText(packageName, installedVersion)); return true; } // fall through. } catch (Exception e) when (FatalError.ReportWithoutCrash(e)) { dte.StatusBar.Text = string.Format(ServicesVSResources.Package_install_failed_colon_0, e.Message); var notificationService = _workspace.Services.GetService(); notificationService?.SendNotification( string.Format(ServicesVSResources.Installing_0_failed_Additional_information_colon_1, packageName, e.Message), severity: NotificationSeverity.Error); // fall through. } return false; } private static string GetStatusBarText(string packageName, string installedVersion) { return installedVersion == null ? packageName : $"{packageName} - {installedVersion}"; } private bool TryUninstallPackage( string packageName, EnvDTE.DTE dte, EnvDTE.Project dteProject) { this.AssertIsForeground(); try { if (_packageServices.IsPackageInstalled(dteProject, packageName)) { dte.StatusBar.Text = string.Format(ServicesVSResources.Uninstalling_0, packageName); var installedVersion = GetInstalledVersion(packageName, dteProject); _packageServices.UninstallPackage(dteProject, packageName, removeDependencies: true); dte.StatusBar.Text = string.Format(ServicesVSResources.Uninstalling_0_completed, GetStatusBarText(packageName, installedVersion)); return true; } // fall through. } catch (Exception e) when (FatalError.ReportWithoutCrash(e)) { dte.StatusBar.Text = string.Format(ServicesVSResources.Package_uninstall_failed_colon_0, e.Message); var notificationService = _workspace.Services.GetService(); notificationService?.SendNotification( string.Format(ServicesVSResources.Uninstalling_0_failed_Additional_information_colon_1, packageName, e.Message), severity: NotificationSeverity.Error); // fall through. } return false; } private string GetInstalledVersion(string packageName, EnvDTE.Project dteProject) { this.AssertIsForeground(); try { var installedPackages = _packageServices.GetInstalledPackages(dteProject); var metadata = installedPackages.FirstOrDefault(m => m.Id == packageName); return metadata?.VersionString; } catch (ArgumentException e) when (IsKnownNugetIssue(e)) { // Nuget may throw an ArgumentException when there is something about the project // they do not like/support. } catch (Exception e) when (FatalError.ReportWithoutCrash(e)) { } return null; } private bool IsKnownNugetIssue(ArgumentException exception) { // See https://github.com/NuGet/Home/issues/4706 // Nuget throws on legal projects. We do not want to report this exception // as it is known (and NFWs are expensive), but we do want to report if we // run into anything else. return exception.Message.Contains("is not a valid version string"); } private void OnWorkspaceChanged(object sender, WorkspaceChangeEventArgs e) { ThisCanBeCalledOnAnyThread(); bool localSolutionChanged = false; ProjectId localChangedProject = null; switch (e.Kind) { default: // Nothing to do for any other events. return; case WorkspaceChangeKind.ProjectAdded: case WorkspaceChangeKind.ProjectChanged: case WorkspaceChangeKind.ProjectReloaded: case WorkspaceChangeKind.ProjectRemoved: localChangedProject = e.ProjectId; break; case WorkspaceChangeKind.SolutionAdded: case WorkspaceChangeKind.SolutionChanged: case WorkspaceChangeKind.SolutionCleared: case WorkspaceChangeKind.SolutionReloaded: case WorkspaceChangeKind.SolutionRemoved: localSolutionChanged = true; break; } lock (_gate) { // Augment the data that the foreground thread will process. _solutionChanged |= localSolutionChanged; if (localChangedProject != null) { _changedProjects.Add(localChangedProject); } // Now cancel any inflight work that is processing the data. _tokenSource.Cancel(); _tokenSource = new CancellationTokenSource(); // And enqueue a new job to process things. Wait one second before starting. // That way if we get a flurry of events we'll end up processing them after // they've all come in. var cancellationToken = _tokenSource.Token; Task.Delay(TimeSpan.FromSeconds(1), cancellationToken) .ContinueWith(_ => ProcessBatchedChangesOnForeground(cancellationToken), cancellationToken, TaskContinuationOptions.OnlyOnRanToCompletion, ForegroundTaskScheduler); } } private void ProcessBatchedChangesOnForeground(CancellationToken cancellationToken) { this.AssertIsForeground(); // If we've been asked to stop, then there's no point proceeding. if (cancellationToken.IsCancellationRequested) { return; } // If we've been disconnected, then there's no point proceeding. if (_workspace == null || _packageServices == null) { return; } // Get a project to process. var solution = _workspace.CurrentSolution; var projectId = DequeueNextProject(solution); if (projectId == null) { // No project to process, nothing to do. return; } // Process this single project. ProcessProjectChange(solution, projectId); // After processing this single project, yield so the foreground thread // can do more work. Then go and loop again so we can process the // rest of the projects. Task.Factory.SafeStartNew( () => ProcessBatchedChangesOnForeground(cancellationToken), cancellationToken, ForegroundTaskScheduler); } private ProjectId DequeueNextProject(Solution solution) { this.AssertIsForeground(); lock (_gate) { // If we detected a solution change, then we need to process all projects. // This includes all the projects that we already know about, as well as // all the projects in the current workspace solution. if (_solutionChanged) { _changedProjects.AddRange(solution.ProjectIds); _changedProjects.AddRange(_projectToInstalledPackageAndVersion.Keys); } _solutionChanged = false; // Remove and return the first project in the list. var projectId = _changedProjects.FirstOrDefault(); _changedProjects.Remove(projectId); return projectId; } } private void ProcessProjectChange(Solution solution, ProjectId projectId) { this.AssertIsForeground(); // Remove anything we have associated with this project. _projectToInstalledPackageAndVersion.TryRemove(projectId, out var projectState); var project = solution.GetProject(projectId); if (project == null) { // Project was removed. Nothing needs to be done. return; } // We really only need to know the NuGet status for managed language projects. // Also, the NuGet APIs may throw on some projects that don't implement the // full set of DTE APIs they expect. So we filter down to just C# and VB here // as we know these languages are safe to build up this index for. if (project.Language != LanguageNames.CSharp && project.Language != LanguageNames.VisualBasic) { return; } // Project was changed in some way. Let's go find the set of installed packages for it. var dteProject = _workspace.TryGetDTEProject(projectId); if (dteProject == null) { // Don't have a DTE project for this project ID. not something we can query NuGet for. return; } var installedPackages = new MultiDictionary(); var isEnabled = false; // Calling into NuGet. Assume they may fail for any reason. try { var installedPackageMetadata = _packageServices.GetInstalledPackages(dteProject); foreach (var metadata in installedPackageMetadata) { if (metadata.VersionString != null) { installedPackages.Add(metadata.Id, metadata.VersionString); } } isEnabled = true; } catch (ArgumentException e) when (IsKnownNugetIssue(e)) { // Nuget may throw an ArgumentException when there is something about the project // they do not like/support. } catch (Exception e) when (FatalError.ReportWithoutCrash(e)) { } var state = new ProjectState(isEnabled, installedPackages); _projectToInstalledPackageAndVersion.AddOrUpdate( projectId, state, (_1, _2) => state); } public bool IsInstalled(Workspace workspace, ProjectId projectId, string packageName) { ThisCanBeCalledOnAnyThread(); return _projectToInstalledPackageAndVersion.TryGetValue(projectId, out var installedPackages) && installedPackages.InstalledPackageToVersion.ContainsKey(packageName); } public ImmutableArray GetInstalledVersions(string packageName) { ThisCanBeCalledOnAnyThread(); var installedVersions = new HashSet(); foreach (var state in _projectToInstalledPackageAndVersion.Values) { installedVersions.AddRange(state.InstalledPackageToVersion[packageName]); } // Order the versions with a weak heuristic so that 'newer' versions come first. // Essentially, we try to break the version on dots, and then we use a LogicalComparer // to try to more naturally order the things we see between the dots. var versionsAndSplits = installedVersions.Select(v => new { Version = v, Split = v.Split('.') }).ToList(); versionsAndSplits.Sort((v1, v2) => { var diff = CompareSplit(v1.Split, v2.Split); return diff != 0 ? diff : -v1.Version.CompareTo(v2.Version); }); return versionsAndSplits.Select(v => v.Version).ToImmutableArray(); } private int CompareSplit(string[] split1, string[] split2) { ThisCanBeCalledOnAnyThread(); for (int i = 0, n = Math.Min(split1.Length, split2.Length); i < n; i++) { // Prefer things that look larger. i.e. 7 should come before 6. // Use a logical string comparer so that 10 is understood to be // greater than 3. var diff = -LogicalStringComparer.Instance.Compare(split1[i], split2[i]); if (diff != 0) { return diff; } } // Choose the one with more parts. return split2.Length - split1.Length; } public IEnumerable GetProjectsWithInstalledPackage(Solution solution, string packageName, string version) { ThisCanBeCalledOnAnyThread(); var result = new List(); foreach (var kvp in this._projectToInstalledPackageAndVersion) { var state = kvp.Value; var versionSet = state.InstalledPackageToVersion[packageName]; if (versionSet.Contains(packageName)) { var project = solution.GetProject(kvp.Key); if (project != null) { result.Add(project); } } } return result; } public void ShowManagePackagesDialog(string packageName) { this.AssertIsForeground(); var shell = (IVsShell)_serviceProvider.GetService(typeof(SVsShell)); if (shell == null) { return; } var nugetGuid = new Guid("5fcc8577-4feb-4d04-ad72-d6c629b083cc"); shell.LoadPackage(ref nugetGuid, out var nugetPackage); if (nugetPackage == null) { return; } // We're able to launch the package manager (with an item in its search box) by // using the IVsSearchProvider API that the NuGet package exposes. // // We get that interface for it and then pass it a SearchQuery that effectively // wraps the package name we're looking for. The NuGet package will then read // out that string and populate their search box with it. var extensionProvider = (IVsPackageExtensionProvider)nugetPackage; var extensionGuid = new Guid("042C2B4B-C7F7-49DB-B7A2-402EB8DC7892"); var emptyGuid = Guid.Empty; var searchProvider = (IVsSearchProvider)extensionProvider.CreateExtensionInstance(ref emptyGuid, ref extensionGuid); var task = searchProvider.CreateSearch(dwCookie: 1, pSearchQuery: new SearchQuery(packageName), pSearchCallback: this); task.Start(); } public void ReportProgress(IVsSearchTask pTask, uint dwProgress, uint dwMaxProgress) { } public void ReportComplete(IVsSearchTask pTask, uint dwResultsFound) { } public void ReportResult(IVsSearchTask pTask, IVsSearchItemResult pSearchItemResult) { pSearchItemResult.InvokeAction(); } public void ReportResults(IVsSearchTask pTask, uint dwResults, IVsSearchItemResult[] pSearchItemResults) { } private class SearchQuery : IVsSearchQuery { public SearchQuery(string packageName) { this.SearchString = packageName; } public string SearchString { get; } public uint ParseError => 0; public uint GetTokens(uint dwMaxTokens, IVsSearchToken[] rgpSearchTokens) { return 0; } } private class PackageServicesProxy : IPackageServicesProxy { private readonly IVsPackageInstaller2 _packageInstaller; private readonly IVsPackageInstallerServices _packageInstallerServices; private readonly IVsPackageSourceProvider _packageSourceProvider; private readonly IVsPackageUninstaller _packageUninstaller; public PackageServicesProxy( IVsPackageInstallerServices packageInstallerServices, IVsPackageInstaller2 packageInstaller, IVsPackageUninstaller packageUninstaller, IVsPackageSourceProvider packageSourceProvider) { _packageInstallerServices = packageInstallerServices; _packageInstaller = packageInstaller; _packageUninstaller = packageUninstaller; _packageSourceProvider = packageSourceProvider; } public event EventHandler SourcesChanged { add { _packageSourceProvider.SourcesChanged += value; } remove { _packageSourceProvider.SourcesChanged -= value; } } public IEnumerable GetInstalledPackages(EnvDTE.Project project) { return _packageInstallerServices.GetInstalledPackages(project) .Select(m => new PackageMetadata(m.Id, m.VersionString)) .ToList(); } public bool IsPackageInstalled(EnvDTE.Project project, string id) => _packageInstallerServices.IsPackageInstalled(project, id); public void InstallPackage(string source, EnvDTE.Project project, string packageId, string version, bool ignoreDependencies) => _packageInstaller.InstallPackage(source, project, packageId, version, ignoreDependencies); public void InstallLatestPackage(string source, EnvDTE.Project project, string packageId, bool includePrerelease, bool ignoreDependencies) => _packageInstaller.InstallLatestPackage(source, project, packageId, includePrerelease, ignoreDependencies); public IEnumerable> GetSources(bool includeUnOfficial, bool includeDisabled) => _packageSourceProvider.GetSources(includeUnOfficial, includeDisabled); public void UninstallPackage(EnvDTE.Project project, string packageId, bool removeDependencies) => _packageUninstaller.UninstallPackage(project, packageId, removeDependencies); } } }