diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cd42ee3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +bin/ +obj/ diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..243a350 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,16 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Attach", + "type": "coreclr", + "request": "attach", + "processId": "${command:pickProcess}", + "targetArchitecture": "arm64", + "justMyCode": false + } + ] +} \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9b2754d --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Steve Lee + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..33d489d --- /dev/null +++ b/README.md @@ -0,0 +1,16 @@ +# WinGetCommandNotFound + +This is a proof-of-concept implementing both the `IFeedbackProvider` interfaces. +`IFeedbackProvider` requires PS7.4+. + +## Feedback provider + +The feedback provider uses WinGet's `Microsoft.Management.Deployment.winmd` as well as their `winrtact.dll`. These can be found in the `Microsoft.WinGet.Client-PSModule.zip` asset from the [WinGet CLI releases page](https://github.com/microsoft/winget-cli/releases). + +## Building + +Go to `src` folder and use `dotnet build`. Requires .NET 8 SDK installed and in path. + +## Using + +Copy `winrtact.dll` from `Microsoft.WinGet.Client-PSModule.zip` to the published folder after building. Then, just `Import-Module WinGetCommandNotFound.psd1` which will register the Feedback Provider. diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..cfb4bda --- /dev/null +++ b/build.ps1 @@ -0,0 +1,10 @@ +if ($env:PROCESSOR_ARCHITECTURE -eq "ARM64") { + $runtime = "win-arm64" +} else { + $runtime = "win-x64" +} + +remove-item -Recurse -Force $PSScriptRoot\bin +push-location $PSScriptRoot\src +dotnet publish -r $runtime -o ..\bin --no-self-contained +pop-location \ No newline at end of file diff --git a/src/Microsoft.Management.Deployment.winmd b/src/Microsoft.Management.Deployment.winmd new file mode 100644 index 0000000..a6579d4 Binary files /dev/null and b/src/Microsoft.Management.Deployment.winmd differ diff --git a/src/WinGetCommandNotFound.cs b/src/WinGetCommandNotFound.cs new file mode 100644 index 0000000..a51ed63 --- /dev/null +++ b/src/WinGetCommandNotFound.cs @@ -0,0 +1,302 @@ +using System.Security.Principal; +using System.Management.Automation; +using System.Management.Automation.Runspaces; +using System.Management.Automation.Subsystem; +using System.Management.Automation.Subsystem.Feedback; +using System.Runtime.InteropServices; +using Microsoft.Management.Deployment; + +namespace wingetprovider +{ + // Adapted from https://github.com/microsoft/winget-cli/blob/1898da0b657585d2e6399ef783ecb667eed280f9/src/PowerShell/Microsoft.WinGet.Client/Helpers/ComObjectFactory.cs + public class ComObjectFactory + { + private static readonly Guid PackageManagerClsid = Guid.Parse("C53A4F16-787E-42A4-B304-29EFFB4BF597"); + private static readonly Guid FindPackagesOptionsClsid = Guid.Parse("572DED96-9C60-4526-8F92-EE7D91D38C1A"); + private static readonly Guid PackageMatchFilterClsid = Guid.Parse("D02C9DAF-99DC-429C-B503-4E504E4AB000"); + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Interoperability", "CA1416:Validate platform compatibility", Justification = "COM only usage.")] + private static readonly Type PackageManagerType = Type.GetTypeFromCLSID(PackageManagerClsid); + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Interoperability", "CA1416:Validate platform compatibility", Justification = "COM only usage.")] + private static readonly Type FindPackagesOptionsType = Type.GetTypeFromCLSID(FindPackagesOptionsClsid); + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Interoperability", "CA1416:Validate platform compatibility", Justification = "COM only usage.")] + private static readonly Type PackageMatchFilterType = Type.GetTypeFromCLSID(PackageMatchFilterClsid); + + private static readonly Guid PackageManagerIid = Guid.Parse("B375E3B9-F2E0-5C93-87A7-B67497F7E593"); + private static readonly Guid FindPackagesOptionsIid = Guid.Parse("A5270EDD-7DA7-57A3-BACE-F2593553561F"); + private static readonly Guid PackageMatchFilterIid = Guid.Parse("D981ECA3-4DE5-5AD7-967A-698C7D60FC3B"); + + public static bool IsAdministrator() + { + WindowsIdentity identity = WindowsIdentity.GetCurrent(); + WindowsPrincipal principal = new WindowsPrincipal(identity); + return principal.IsInRole(WindowsBuiltInRole.Administrator); + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Interoperability", "CA1416:Validate platform compatibility", Justification = "COM only usage.")] + private static T Create(Type type, in Guid iid) + { + object instance = null; + if (IsAdministrator()) + { + var hr = WinGetServerManualActivation_CreateInstance(type.GUID, iid, 0, out instance); + if (hr < 0) + { + throw new COMException($"Failed to create instance: {hr}", hr); + } + } + else + { + instance = Activator.CreateInstance(type); + } + + IntPtr pointer = Marshal.GetIUnknownForObject(instance); + return WinRT.MarshalInterface.FromAbi(pointer); + } + + [DllImport("winrtact.dll", EntryPoint = "WinGetServerManualActivation_CreateInstance", ExactSpelling = true, PreserveSig = true)] + private static extern int WinGetServerManualActivation_CreateInstance( + [In, MarshalAs(UnmanagedType.LPStruct)] Guid clsid, + [In, MarshalAs(UnmanagedType.LPStruct)] Guid iid, + uint flags, + [Out, MarshalAs(UnmanagedType.IUnknown)] out object instance); + + [DllImport("winrtact.dll", EntryPoint = "winrtact_Initialize", ExactSpelling = true, PreserveSig = true)] + public static extern void InitializeUndockedRegFreeWinRT(); + + public static PackageManager CreatePackageManager() + { + return Create(PackageManagerType, PackageManagerIid); + } + + public static FindPackagesOptions CreateFindPackagesOptions() + { + return Create(FindPackagesOptionsType, FindPackagesOptionsIid); + } + + public static PackageMatchFilter CreatePackageMatchFilter() + { + return Create(PackageMatchFilterType, PackageMatchFilterIid); + } + } + + public sealed class WinGetComObjects + { + public static WinGetComObjects Singleton { get; } = new WinGetComObjects(); + + private WinGetComObjects() + { + ComObjectFactory.InitializeUndockedRegFreeWinRT(); + packageManager = ComObjectFactory.CreatePackageManager(); + findPackagesOptions = ComObjectFactory.CreateFindPackagesOptions(); + packageMatchFilter = ComObjectFactory.CreatePackageMatchFilter(); + } + + public PackageManager packageManager { get; } + public FindPackagesOptions findPackagesOptions { get; } + public PackageMatchFilter packageMatchFilter { get; } + } + + public sealed class Init : IModuleAssemblyInitializer, IModuleAssemblyCleanup + { + internal const string id = "e5351aa4-dfde-4d4d-bf0f-1a2f5a37d8d6"; + + public void OnImport() + { + if (!Platform.IsWindows) + { + return; + } + + // Ensure WinGet is installed + using (var rs = RunspaceFactory.CreateRunspace(InitialSessionState.CreateDefault())) + { + rs.Open(); + var invocation = rs.SessionStateProxy.InvokeCommand; + var winget = invocation.GetCommand("winget", CommandTypes.Application); + if (winget is null) + { + return; + } + } + + SubsystemManager.RegisterSubsystem(WinGetCommandNotFoundFeedbackPredictor.Singleton); + } + + public void OnRemove(PSModuleInfo psModuleInfo) + { + SubsystemManager.UnregisterSubsystem(new Guid(id)); + } + } + + public sealed class WinGetCommandNotFoundFeedbackPredictor : IFeedbackProvider + { + private readonly Guid _guid; + private bool _tooManySuggestions; + + private static readonly byte _maxSuggestions = 5; + + public static WinGetCommandNotFoundFeedbackPredictor Singleton { get; } = new WinGetCommandNotFoundFeedbackPredictor(Init.id); + private WinGetCommandNotFoundFeedbackPredictor(string guid) + { + _guid = new Guid(guid); + _tooManySuggestions = false; + } + + public void Dispose() + { + } + + public Guid Id => _guid; + + public string Name => "Windows Package Manager - WinGet"; + + public string Description => "Finds missing commands that can be installed via WinGet."; + + /// + /// Gets feedback based on the given commandline and error record. + /// + public FeedbackItem? GetFeedback(string commandLine, ErrorRecord lastError, CancellationToken token) + { + if (lastError.FullyQualifiedErrorId == "CommandNotFoundException") + { + var target = (string)lastError.TargetObject; + var pkgList = _FindPackages(target); + if (pkgList.Count == 0) + { + return null; + } + + // Build list of suggestions + var suggestionList = new List(); + foreach (var pkg in pkgList) + { + suggestionList.Add(String.Format("winget install --id {0}", pkg.Id)); + } + + // Build footer message + var filterFieldString = WinGetComObjects.Singleton.packageMatchFilter.Field == PackageMatchField.Command ? "command" : "name"; + var footerMessage = _tooManySuggestions ? + String.Format("Additional results can be found using \"winget search --{0} {1}\"", filterFieldString, WinGetComObjects.Singleton.packageMatchFilter.Value) : + null; + + return new FeedbackItem( + "Try installing this package using winget:", + suggestionList, + footerMessage, + FeedbackDisplayLayout.Portrait + ); + } + return null; + } + + private void _ApplyPackageMatchFilter(PackageMatchField field, PackageFieldMatchOption matchOption, string query) + { + // Configure filter + WinGetComObjects.Singleton.packageMatchFilter.Field = field; + WinGetComObjects.Singleton.packageMatchFilter.Option = matchOption; + WinGetComObjects.Singleton.packageMatchFilter.Value = query; + + // Apply filter + WinGetComObjects.Singleton.findPackagesOptions.ResultLimit = _maxSuggestions + 1u; + WinGetComObjects.Singleton.findPackagesOptions.Filters.Clear(); + WinGetComObjects.Singleton.findPackagesOptions.Filters.Add(WinGetComObjects.Singleton.packageMatchFilter); + } + + private List _TryGetBestMatchingPackage(IReadOnlyList matches) + { + var results = new List(); + if (matches.Count == 1) + { + // One match --> return the package + results.Add(matches.First().CatalogPackage); + } + else if (matches.Count > 1) + { + // Multiple matches --> display top 5 matches (prioritize best matches first) + var bestExactMatches = new List(); + var secondaryMatches = new List(); + var tertiaryMatches = new List(); + for (int i = 0; i < matches.Count; i++) + { + var match = matches[i]; + switch (match.MatchCriteria.Option) + { + case PackageFieldMatchOption.EqualsCaseInsensitive: + case PackageFieldMatchOption.Equals: + bestExactMatches.Add(match.CatalogPackage); + break; + case PackageFieldMatchOption.StartsWithCaseInsensitive: + secondaryMatches.Add(match.CatalogPackage); + break; + case PackageFieldMatchOption.ContainsCaseInsensitive: + tertiaryMatches.Add(match.CatalogPackage); + break; + } + } + + // Now return the top _maxSuggestions + while (results.Count < _maxSuggestions) + { + if (bestExactMatches.Count > 0) + { + results.Add(bestExactMatches.First()); + bestExactMatches.RemoveAt(0); + } + else if (secondaryMatches.Count > 0) + { + results.Add(secondaryMatches.First()); + secondaryMatches.RemoveAt(0); + } + else if (tertiaryMatches.Count > 0) + { + results.Add(tertiaryMatches.First()); + tertiaryMatches.RemoveAt(0); + } + else + { + break; + } + } + } + _tooManySuggestions = matches.Count > _maxSuggestions; + return results; + } + + // Adapted from WinGet sample documentation: https://github.com/microsoft/winget-cli/blob/master/doc/specs/%23888%20-%20Com%20Api.md#32-search + private List _FindPackages(string query) + { + // Get the package catalog + var catalogRef = WinGetComObjects.Singleton.packageManager.GetPredefinedPackageCatalog(PredefinedPackageCatalog.OpenWindowsCatalog); + var connectResult = catalogRef.Connect(); + byte retryCount = 0; + while (connectResult.Status != ConnectResultStatus.Ok && retryCount < 3) + { + connectResult = catalogRef.Connect(); + retryCount++; + } + var catalog = connectResult.PackageCatalog; + + // Perform the query (search by command) + _ApplyPackageMatchFilter(PackageMatchField.Command, PackageFieldMatchOption.StartsWithCaseInsensitive, query); + var findPackagesResult = catalog.FindPackages(WinGetComObjects.Singleton.findPackagesOptions); + var matches = findPackagesResult.Matches; + var pkgList = _TryGetBestMatchingPackage(matches); + if (pkgList.Count > 0) + { + return pkgList; + } + + // No matches found when searching by command, + // let's try again and search by name + _ApplyPackageMatchFilter(PackageMatchField.Name, PackageFieldMatchOption.ContainsCaseInsensitive, query); + + // Perform the query (search by name) + findPackagesResult = catalog.FindPackages(WinGetComObjects.Singleton.findPackagesOptions); + matches = findPackagesResult.Matches; + return _TryGetBestMatchingPackage(matches); + } + } +} \ No newline at end of file diff --git a/src/WinGetCommandNotFound.csproj b/src/WinGetCommandNotFound.csproj new file mode 100644 index 0000000..22ebc18 --- /dev/null +++ b/src/WinGetCommandNotFound.csproj @@ -0,0 +1,43 @@ + + + + net8.0-windows10.0.22621.0 + AnyCPU + enable + enable + false + ..\bin\WinGetCommandNotFound + true + win-arm64;win-x64 + + + + Microsoft.Management.Deployment + 10.0.22621.0 + $(OutDir) + + + + + + + + + false + None + + + + + contentFiles + All + + + + + PreserveNewest + PreserveNewest + + + + diff --git a/src/WinGetCommandNotFound.psd1 b/src/WinGetCommandNotFound.psd1 new file mode 100644 index 0000000..0d273fd --- /dev/null +++ b/src/WinGetCommandNotFound.psd1 @@ -0,0 +1,10 @@ +@{ + ModuleVersion = '0.1.0' + GUID = '28c9afa2-92e5-413e-8e53-44b2d7a83ac6' + Author = 'Carlos Zamora' + CompanyName = "Microsoft Corporation" + Copyright = "Copyright (c) Microsoft Corporation." + Description = 'Enable suggestions on how to install missing commands via winget' + PowerShellVersion = '7.4' + NestedModules = @('WinGetCommandNotFound.dll') +}