From 7c4473c2c18d0f016712152d22b346fa1fe6af7f Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Wed, 14 Dec 2022 20:44:44 -0800 Subject: [PATCH 01/17] Initial commit --- LICENSE | 21 +++++++++++++++++++++ README.md | 1 + 2 files changed, 22 insertions(+) create mode 100644 LICENSE create mode 100644 README.md 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..7b0bd53 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# WinGetPredictor \ No newline at end of file From eeab480964053fdd48a58a41065ff9f6576a3357 Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Tue, 27 Dec 2022 00:42:45 -0800 Subject: [PATCH 02/17] Initial proof-of-concept using winget.exe --- .gitignore | 2 + .vscode/launch.json | 16 +++ src/WinGetCommandNotFound.cs | 191 +++++++++++++++++++++++++++++++ src/WinGetCommandNotFound.csproj | 32 ++++++ src/WinGetCommandNotFound.psd1 | 10 ++ 5 files changed, 251 insertions(+) create mode 100644 .gitignore create mode 100644 .vscode/launch.json create mode 100644 src/WinGetCommandNotFound.cs create mode 100644 src/WinGetCommandNotFound.csproj create mode 100644 src/WinGetCommandNotFound.psd1 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/src/WinGetCommandNotFound.cs b/src/WinGetCommandNotFound.cs new file mode 100644 index 0000000..b04c1d1 --- /dev/null +++ b/src/WinGetCommandNotFound.cs @@ -0,0 +1,191 @@ +using System.Diagnostics; +using System.Management.Automation; +using System.Management.Automation.Internal; +using System.Management.Automation.Runspaces; +using System.Management.Automation.Subsystem; +using System.Management.Automation.Subsystem.Feedback; +using System.Management.Automation.Subsystem.Prediction; +using System.Threading.Tasks; + +namespace wingetprovider +{ + public sealed class Init : IModuleAssemblyInitializer, IModuleAssemblyCleanup + { + private const string feedbackId = "e5351aa4-dfde-4d4d-bf0f-1a2f5a37d8d6"; + private const string predictorId = "b0fcf338-b1d8-43f6-bcb9-aadf697b9706"; + + public void OnImport() + { + if (!Platform.IsWindows) + { + return; + } + + 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; + } + } + + // make sure latest index.db is loaded + var task = Task.Run(() => + { + var psi = new ProcessStartInfo("winget", "source update"); + psi.CreateNoWindow = true; + psi.RedirectStandardOutput = true; + psi.RedirectStandardError = true; + Process.Start(psi); + }); + + SubsystemManager.RegisterSubsystem(new WinGetCommandNotFoundFeedback(feedbackId)); + SubsystemManager.RegisterSubsystem(new WinGetCommandNotFoundPredictor(predictorId)); + } + + public void OnRemove(PSModuleInfo psModuleInfo) + { + SubsystemManager.UnregisterSubsystem(new Guid(feedbackId)); + SubsystemManager.UnregisterSubsystem(new Guid(predictorId)); + } + } + + public sealed class WinGetCommandNotFoundFeedback : IFeedbackProvider + { + private readonly Guid _guid; + + public WinGetCommandNotFoundFeedback(string guid) + { + _guid = new Guid(guid); + } + + public Guid Id => _guid; + + public string Name => "winget-cmd-not-found"; + + public string Description => "Finds missing commands that can be installed via winget."; + + /// + /// Gets feedback based on the given commandline and error record. + /// + public string? GetFeedback(string commandLine, ErrorRecord lastError, CancellationToken token) + { + if (lastError.FullyQualifiedErrorId == "CommandNotFoundException") + { + var target = (string)lastError.TargetObject; + + if (target == "kubectl") + { + return "winget install kubernetes-cli"; + } + + // would be better to use SQL queries against the index.db SQLite database, + // but this is just a proof of concept to demonstrate the user experience + var psi = new ProcessStartInfo("winget", "search --command " + target); + psi.CreateNoWindow = true; + psi.RedirectStandardOutput = true; + psi.RedirectStandardError = true; + var p = Process.Start(psi); + var output = p?.StandardOutput.ReadToEnd(); + p?.WaitForExit(); + if (p?.ExitCode == 0 && output is not null && output.Length > 0) + { + var lines = output.Split('\n'); + if (lines.Length > 2) + { + var line = lines[2]; + if (line.Length > 0) + { + var parts = line.Split(' ', 3); + if (parts.Length == 3) + { + string suggestion = "winget install " + parts[1]; + WinGetCommandNotFoundPredictor.WingetPrediction = suggestion; + return suggestion; + } + } + } + } + } + + return null; + } + } + + public class WinGetCommandNotFoundPredictor : ICommandPredictor + { + private readonly Guid _guid; + internal static string? _wingetPrediction; + private static object _lock = new object(); + + internal static string? WingetPrediction + { + get { + lock (_lock) + { + return _wingetPrediction; + } + } + set { + lock (_lock) + { + _wingetPrediction = value; + } + } + } + + public WinGetCommandNotFoundPredictor(string guid) + { + _guid = new Guid(guid); + } + + public Guid Id => _guid; + + public string Name => "winget-cmd-not-found-predictor"; + + public string Description => "Predict the install command for missing commands via winget."; + + public bool CanAcceptFeedback(PredictionClient client, PredictorFeedbackKind feedback) + { + return feedback switch + { + PredictorFeedbackKind.CommandLineAccepted => true, + _ => false, + }; + } + + public SuggestionPackage GetSuggestion(PredictionClient client, PredictionContext context, CancellationToken cancellationToken) + { + List? result = null; + + result ??= new List(1); + if (WingetPrediction is null) + { + return default; + } + + result.Add(new PredictiveSuggestion(WingetPrediction)); + + if (result is not null) + { + return new SuggestionPackage(result); + } + + return default; + } + + public void OnCommandLineAccepted(PredictionClient client, IReadOnlyList history) + { + WingetPrediction = null; + } + + public void OnSuggestionDisplayed(PredictionClient client, uint session, int countOrIndex) { } + + public void OnSuggestionAccepted(PredictionClient client, uint session, string acceptedSuggestion) { } + + public void OnCommandLineExecuted(PredictionClient client, string commandLine, bool success) { } + } +} \ No newline at end of file diff --git a/src/WinGetCommandNotFound.csproj b/src/WinGetCommandNotFound.csproj new file mode 100644 index 0000000..b0981fc --- /dev/null +++ b/src/WinGetCommandNotFound.csproj @@ -0,0 +1,32 @@ + + + + net7.0 + enable + enable + + + false + + + ..\bin\WinGetCommandNotFound + + + + + false + None + + + + + contentFiles + All + + + PreserveNewest + PreserveNewest + + + + diff --git a/src/WinGetCommandNotFound.psd1 b/src/WinGetCommandNotFound.psd1 new file mode 100644 index 0000000..3a502f5 --- /dev/null +++ b/src/WinGetCommandNotFound.psd1 @@ -0,0 +1,10 @@ +@{ + ModuleVersion = '0.1.0' + GUID = '28c9afa2-92e5-413e-8e53-44b2d7a83ac6' + Author = 'Steve Lee' + 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') +} From 6cf565036c39a0eabdfdfca20d25be57ec93c662 Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Tue, 27 Dec 2022 03:27:22 -0800 Subject: [PATCH 03/17] change to use sqlite directly --- src/WinGetCommandNotFound.cs | 75 ++++++++++++++++++-------------- src/WinGetCommandNotFound.csproj | 11 ++--- 2 files changed, 49 insertions(+), 37 deletions(-) diff --git a/src/WinGetCommandNotFound.cs b/src/WinGetCommandNotFound.cs index b04c1d1..a222d25 100644 --- a/src/WinGetCommandNotFound.cs +++ b/src/WinGetCommandNotFound.cs @@ -1,6 +1,7 @@ +using Microsoft.Data.Sqlite; using System.Diagnostics; +using System.IO; using System.Management.Automation; -using System.Management.Automation.Internal; using System.Management.Automation.Runspaces; using System.Management.Automation.Subsystem; using System.Management.Automation.Subsystem.Feedback; @@ -35,7 +36,7 @@ public void OnImport() // make sure latest index.db is loaded var task = Task.Run(() => { - var psi = new ProcessStartInfo("winget", "source update"); + var psi = new ProcessStartInfo("winget", "source update --name winget"); psi.CreateNoWindow = true; psi.RedirectStandardOutput = true; psi.RedirectStandardError = true; @@ -56,10 +57,28 @@ public void OnRemove(PSModuleInfo psModuleInfo) public sealed class WinGetCommandNotFoundFeedback : IFeedbackProvider { private readonly Guid _guid; + private SqliteConnection? _dbConnection; public WinGetCommandNotFoundFeedback(string guid) { _guid = new Guid(guid); + // Trying to enumerate WindowsApps folder results in AccessDenied, + // so using a hardcoded path for now + var dbPath = new FileInfo(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles) + @"\WindowsApps\Microsoft.Winget.Source_2022.1227.1114.286_neutral__8wekyb3d8bbwe\public\index.db"); + if (!dbPath.Exists) + { + throw new Exception("Could not find index.db"); + } + + // open connection to index.db + _dbConnection = new SqliteConnection("Data Source=" + dbPath); + _dbConnection.Open(); + } + + public void Dispose() + { + _dbConnection?.Close(); + _dbConnection?.Dispose(); } public Guid Id => _guid; @@ -73,40 +92,32 @@ public WinGetCommandNotFoundFeedback(string guid) /// public string? GetFeedback(string commandLine, ErrorRecord lastError, CancellationToken token) { - if (lastError.FullyQualifiedErrorId == "CommandNotFoundException") + if (_dbConnection is not null && lastError.FullyQualifiedErrorId == "CommandNotFoundException") { var target = (string)lastError.TargetObject; - - if (target == "kubectl") - { - return "winget install kubernetes-cli"; - } - - // would be better to use SQL queries against the index.db SQLite database, - // but this is just a proof of concept to demonstrate the user experience - var psi = new ProcessStartInfo("winget", "search --command " + target); - psi.CreateNoWindow = true; - psi.RedirectStandardOutput = true; - psi.RedirectStandardError = true; - var p = Process.Start(psi); - var output = p?.StandardOutput.ReadToEnd(); - p?.WaitForExit(); - if (p?.ExitCode == 0 && output is not null && output.Length > 0) + var command = _dbConnection.CreateCommand(); + command.CommandText = + @" + SELECT + ids.id + FROM + commands, commands_map, manifest, ids + WHERE + commands.command = $command + AND commands.rowid = commands_map.command + AND manifest.rowid = commands_map.manifest + AND ids.rowid = manifest.id + LIMIT 1 + "; + command.Parameters.AddWithValue("$command", target); + + using (var reader = command.ExecuteReader()) { - var lines = output.Split('\n'); - if (lines.Length > 2) + while (reader.Read()) { - var line = lines[2]; - if (line.Length > 0) - { - var parts = line.Split(' ', 3); - if (parts.Length == 3) - { - string suggestion = "winget install " + parts[1]; - WinGetCommandNotFoundPredictor.WingetPrediction = suggestion; - return suggestion; - } - } + var suggestion = "winget install " + reader.GetString(0); + WinGetCommandNotFoundPredictor.WingetPrediction = suggestion; + return suggestion; } } } diff --git a/src/WinGetCommandNotFound.csproj b/src/WinGetCommandNotFound.csproj index b0981fc..2b89720 100644 --- a/src/WinGetCommandNotFound.csproj +++ b/src/WinGetCommandNotFound.csproj @@ -2,14 +2,13 @@ net7.0 + enable enable - - false - - ..\bin\WinGetCommandNotFound + true + win-arm64;win-x64 @@ -22,7 +21,9 @@ contentFiles All - + + + PreserveNewest PreserveNewest From 5f445563721041382b967e17816b6dda556f39d0 Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Tue, 27 Dec 2022 03:43:49 -0800 Subject: [PATCH 04/17] Update README.md --- README.md | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 7b0bd53..2fc1c47 100644 --- a/README.md +++ b/README.md @@ -1 +1,22 @@ -# WinGetPredictor \ No newline at end of file +# WinGetCommandNotFound + +This is a proof-of-concept implementing both the `IFeedbackProvider` and `ICommandPredictor` interfaces. +`IFeedbackProvider` requires PS7.4+. + +**This is NOT intended to be used outside of a demo with no intent to take this to production** + +## Feedback provider + +The feedback provider uses the existing sqlite database used by winget instead of downloading and expanding +its own copy. + +Because the `WindowsApps` folder is protected, this implementation currently uses a hardcoded path to the +`index.db` file in "$env:ProgramFiles\WindowsApps\Microsoft.Winget.Source_2022.1227.1114.286_neutral__8wekyb3d8bbwe\public\index.db". +A different version of Winget will use a different path. + +I've only tested this on win-arm64, but it builds for win-x64 runtime, so it should work. + +## Command predictor + +PSReadLine currently does not have a way to present a prediction without a keypress. +The suggestion from the feedback provider is given as a prediction, but will require pressing `w` for the prediction to show. From 7b43d658d42bd0735ef94ae23d37c4aa701e6d19 Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Tue, 27 Dec 2022 03:49:57 -0800 Subject: [PATCH 05/17] Update README.md --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 2fc1c47..8a7e0e9 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,8 @@ This is a proof-of-concept implementing both the `IFeedbackProvider` and `IComma **This is NOT intended to be used outside of a demo with no intent to take this to production** +![Screen-Recording-2022-12-27-at-8](https://user-images.githubusercontent.com/11859881/209662484-c739d16b-3dbd-44be-84b5-2402bcfadbbe.gif) + ## Feedback provider The feedback provider uses the existing sqlite database used by winget instead of downloading and expanding From 1b7ae9c752ed4369fa923108606cbd28a7dff12f Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Tue, 27 Dec 2022 04:04:35 -0800 Subject: [PATCH 06/17] Update README.md --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 8a7e0e9..308e478 100644 --- a/README.md +++ b/README.md @@ -22,3 +22,7 @@ I've only tested this on win-arm64, but it builds for win-x64 runtime, so it sho PSReadLine currently does not have a way to present a prediction without a keypress. The suggestion from the feedback provider is given as a prediction, but will require pressing `w` for the prediction to show. + +## Building + +Go to `src` folder and use `dotnet build`. Requires .NET 7 SDK installed and in path. From 06c19652395dbf073b024e340d361d64e19f81ce Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Fri, 3 Feb 2023 09:15:43 -0800 Subject: [PATCH 07/17] update to new path for db --- src/WinGetCommandNotFound.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/WinGetCommandNotFound.cs b/src/WinGetCommandNotFound.cs index a222d25..682158b 100644 --- a/src/WinGetCommandNotFound.cs +++ b/src/WinGetCommandNotFound.cs @@ -64,7 +64,7 @@ public WinGetCommandNotFoundFeedback(string guid) _guid = new Guid(guid); // Trying to enumerate WindowsApps folder results in AccessDenied, // so using a hardcoded path for now - var dbPath = new FileInfo(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles) + @"\WindowsApps\Microsoft.Winget.Source_2022.1227.1114.286_neutral__8wekyb3d8bbwe\public\index.db"); + var dbPath = new FileInfo(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles) + @"\WindowsApps\Microsoft.Winget.Source_2023.202.1839.736_neutral__8wekyb3d8bbwe\public\index.db"); if (!dbPath.Exists) { throw new Exception("Could not find index.db"); From f5d20bc39e20f3e62200e8189c15f7a3e92dac01 Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Fri, 3 Feb 2023 09:18:12 -0800 Subject: [PATCH 08/17] Update README.md --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 308e478..171ce6c 100644 --- a/README.md +++ b/README.md @@ -26,3 +26,8 @@ The suggestion from the feedback provider is given as a prediction, but will req ## Building Go to `src` folder and use `dotnet build`. Requires .NET 7 SDK installed and in path. + +## Using + +In the published folder, just `Import-Module WinGetCommandNotFound.psd1` which will register the Feedback Provider and Predictor +Then type a command you don't have installed. From 474bca798504bacc40da8ea54db7d2faf6f2fc7e Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Fri, 24 Feb 2023 13:19:15 -0800 Subject: [PATCH 09/17] update path to winget dir --- src/WinGetCommandNotFound.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/WinGetCommandNotFound.cs b/src/WinGetCommandNotFound.cs index 682158b..9e23626 100644 --- a/src/WinGetCommandNotFound.cs +++ b/src/WinGetCommandNotFound.cs @@ -64,7 +64,7 @@ public WinGetCommandNotFoundFeedback(string guid) _guid = new Guid(guid); // Trying to enumerate WindowsApps folder results in AccessDenied, // so using a hardcoded path for now - var dbPath = new FileInfo(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles) + @"\WindowsApps\Microsoft.Winget.Source_2023.202.1839.736_neutral__8wekyb3d8bbwe\public\index.db"); + var dbPath = new FileInfo(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles) + @"\WindowsApps\Microsoft.Winget.Source_2023.224.1920.241_neutral__8wekyb3d8bbwe\public\index.db"); if (!dbPath.Exists) { throw new Exception("Could not find index.db"); From 3ad232429e798e8c23aea0dbd8ace97bd80b0d69 Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Thu, 9 Mar 2023 06:43:15 -0800 Subject: [PATCH 10/17] update db location --- src/WinGetCommandNotFound.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/WinGetCommandNotFound.cs b/src/WinGetCommandNotFound.cs index 9e23626..902a605 100644 --- a/src/WinGetCommandNotFound.cs +++ b/src/WinGetCommandNotFound.cs @@ -64,7 +64,7 @@ public WinGetCommandNotFoundFeedback(string guid) _guid = new Guid(guid); // Trying to enumerate WindowsApps folder results in AccessDenied, // so using a hardcoded path for now - var dbPath = new FileInfo(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles) + @"\WindowsApps\Microsoft.Winget.Source_2023.224.1920.241_neutral__8wekyb3d8bbwe\public\index.db"); + var dbPath = new FileInfo(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles) + @"\WindowsApps\Microsoft.Winget.Source_2023.309.1003.650_neutral__8wekyb3d8bbwe\Public\index.db"); if (!dbPath.Exists) { throw new Exception("Could not find index.db"); From b344e8f6a9c6c2e6792891ceb9f5b1466d418bd4 Mon Sep 17 00:00:00 2001 From: Carlos Zamora Date: Mon, 13 Mar 2023 17:17:45 -0700 Subject: [PATCH 11/17] replace database with WinGet API --- build.ps1 | 10 + src/Microsoft.Management.Deployment.winmd | Bin 0 -> 37280 bytes src/WinGetCommandNotFound.cs | 274 ++++++++++++++-------- src/WinGetCommandNotFound.csproj | 75 +++--- 4 files changed, 235 insertions(+), 124 deletions(-) create mode 100644 build.ps1 create mode 100644 src/Microsoft.Management.Deployment.winmd 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 0000000000000000000000000000000000000000..a6579d4753bceaa49a97f3d3e0b7ca1279039df8 GIT binary patch literal 37280 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

2txlyasg*bW+3~?g#O{`QSKN&zzMUiO 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 literal 0 HcmV?d00001 diff --git a/src/WinGetCommandNotFound.cs b/src/WinGetCommandNotFound.cs index 902a605..b5a59d2 100644 --- a/src/WinGetCommandNotFound.cs +++ b/src/WinGetCommandNotFound.cs @@ -1,19 +1,87 @@ -using Microsoft.Data.Sqlite; using System.Diagnostics; -using System.IO; using System.Management.Automation; using System.Management.Automation.Runspaces; using System.Management.Automation.Subsystem; using System.Management.Automation.Subsystem.Feedback; using System.Management.Automation.Subsystem.Prediction; -using System.Threading.Tasks; +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"); + + [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; + + // TODO CARLOS: Looks like we need a different activation path if we're running as admin + //if (Utilities.ExecutingAsAdministrator) + //{ + // int 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 Init : IModuleAssemblyInitializer, IModuleAssemblyCleanup { - private const string feedbackId = "e5351aa4-dfde-4d4d-bf0f-1a2f5a37d8d6"; - private const string predictorId = "b0fcf338-b1d8-43f6-bcb9-aadf697b9706"; + internal const string id = "e5351aa4-dfde-4d4d-bf0f-1a2f5a37d8d6"; public void OnImport() { @@ -22,6 +90,7 @@ public void OnImport() return; } + // Ensure WinGet is installed using (var rs = RunspaceFactory.CreateRunspace(InitialSessionState.CreateDefault())) { rs.Open(); @@ -33,132 +102,145 @@ public void OnImport() } } - // make sure latest index.db is loaded - var task = Task.Run(() => - { - var psi = new ProcessStartInfo("winget", "source update --name winget"); - psi.CreateNoWindow = true; - psi.RedirectStandardOutput = true; - psi.RedirectStandardError = true; - Process.Start(psi); - }); - - SubsystemManager.RegisterSubsystem(new WinGetCommandNotFoundFeedback(feedbackId)); - SubsystemManager.RegisterSubsystem(new WinGetCommandNotFoundPredictor(predictorId)); + SubsystemManager.RegisterSubsystem(WinGetCommandNotFoundFeedbackPredictor.Singleton); + SubsystemManager.RegisterSubsystem(WinGetCommandNotFoundFeedbackPredictor.Singleton); } public void OnRemove(PSModuleInfo psModuleInfo) { - SubsystemManager.UnregisterSubsystem(new Guid(feedbackId)); - SubsystemManager.UnregisterSubsystem(new Guid(predictorId)); + SubsystemManager.UnregisterSubsystem(new Guid(id)); + SubsystemManager.UnregisterSubsystem(new Guid(id)); } } - public sealed class WinGetCommandNotFoundFeedback : IFeedbackProvider + public sealed class WinGetCommandNotFoundFeedbackPredictor : IFeedbackProvider, ICommandPredictor { private readonly Guid _guid; - private SqliteConnection? _dbConnection; + private string? _suggestion; + private PackageManager _packageManager; + Dictionary? ISubsystem.FunctionsToDefine => null; - public WinGetCommandNotFoundFeedback(string guid) + public static WinGetCommandNotFoundFeedbackPredictor Singleton { get; } = new WinGetCommandNotFoundFeedbackPredictor(Init.id); + private WinGetCommandNotFoundFeedbackPredictor(string guid) { _guid = new Guid(guid); - // Trying to enumerate WindowsApps folder results in AccessDenied, - // so using a hardcoded path for now - var dbPath = new FileInfo(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles) + @"\WindowsApps\Microsoft.Winget.Source_2023.309.1003.650_neutral__8wekyb3d8bbwe\Public\index.db"); - if (!dbPath.Exists) - { - throw new Exception("Could not find index.db"); - } - - // open connection to index.db - _dbConnection = new SqliteConnection("Data Source=" + dbPath); - _dbConnection.Open(); + ComObjectFactory.InitializeUndockedRegFreeWinRT(); + _packageManager = ComObjectFactory.CreatePackageManager(); } public void Dispose() { - _dbConnection?.Close(); - _dbConnection?.Dispose(); } public Guid Id => _guid; - public string Name => "winget-cmd-not-found"; + public string Name => "Windows Package Manager - WinGet"; - public string Description => "Finds missing commands that can be installed via winget."; + public string Description => "Finds missing commands that can be installed via WinGet."; ///

/// Gets feedback based on the given commandline and error record. /// - public string? GetFeedback(string commandLine, ErrorRecord lastError, CancellationToken token) + public FeedbackItem? GetFeedback(string commandLine, ErrorRecord lastError, CancellationToken token) { - if (_dbConnection is not null && lastError.FullyQualifiedErrorId == "CommandNotFoundException") + if (lastError.FullyQualifiedErrorId == "CommandNotFoundException") { var target = (string)lastError.TargetObject; - var command = _dbConnection.CreateCommand(); - command.CommandText = - @" - SELECT - ids.id - FROM - commands, commands_map, manifest, ids - WHERE - commands.command = $command - AND commands.rowid = commands_map.command - AND manifest.rowid = commands_map.manifest - AND ids.rowid = manifest.id - LIMIT 1 - "; - command.Parameters.AddWithValue("$command", target); - - using (var reader = command.ExecuteReader()) + var package = FindPackage(target); + if (package is null) { - while (reader.Read()) - { - var suggestion = "winget install " + reader.GetString(0); - WinGetCommandNotFoundPredictor.WingetPrediction = suggestion; - return suggestion; - } + return null; } + _suggestion = "winget install --id " + package.Id; + return new FeedbackItem( + Name, + new List { _suggestion } + ); } - return null; } - } - - public class WinGetCommandNotFoundPredictor : ICommandPredictor - { - private readonly Guid _guid; - internal static string? _wingetPrediction; - private static object _lock = new object(); - internal static string? WingetPrediction - { - get { - lock (_lock) - { - return _wingetPrediction; - } - } - set { - lock (_lock) - { - _wingetPrediction = value; - } - } + private CatalogPackage? TryGetBestMatchingPackage(IReadOnlyList matches) + { + if (matches.Count == 1) + { + // One match --> return the package + return matches.First().CatalogPackage; + } + else if (matches.Count > 1) + { + // Multiple matches --> return the one with the shortest match that starts with the query + MatchResult? bestMatch = null; + for (int i = 0; i < matches.Count; i++) + { + var match = matches[i]; + if (match.MatchCriteria.Option == PackageFieldMatchOption.EqualsCaseInsensitive || match.MatchCriteria.Option == PackageFieldMatchOption.Equals) + { + // Exact match --> return the package + return match.CatalogPackage; + } + else if (match.MatchCriteria.Option == PackageFieldMatchOption.StartsWithCaseInsensitive) + { + // get the shortest match that starts with the query + if (bestMatch == null || match.MatchCriteria.Value.Length < bestMatch.MatchCriteria.Value.Length) + { + bestMatch = match; + } + } + } + // TODO CARLOS: bestMatch == null if only ContainsCaseInsensitive matches exist. + // We should figure out a better way to handle this rather than just returning the first package. + return bestMatch == null ? + matches.First().CatalogPackage : + bestMatch.CatalogPackage; + } + return null; } - public WinGetCommandNotFoundPredictor(string guid) + // Adapted from WinGet sample documentation: https://github.com/microsoft/winget-cli/blob/master/doc/specs/%23888%20-%20Com%20Api.md#32-search + private CatalogPackage? FindPackage(string query) { - _guid = new Guid(guid); + // Get the package catalog + var catalogRef = _packageManager.GetPredefinedPackageCatalog(PredefinedPackageCatalog.OpenWindowsCatalog); + var connectResult = catalogRef.Connect(); + if (connectResult.Status != ConnectResultStatus.Ok) + { + // TODO CARLOS: do better if ConnectResultStatus != Ok (aka CatalogError) + return null; + } + var catalog = connectResult.PackageCatalog; + + // Configure query for the package catalog + var findPackagesOptions = ComObjectFactory.CreateFindPackagesOptions(); + var filter = ComObjectFactory.CreatePackageMatchFilter(); + filter.Field = PackageMatchField.Command; + filter.Option = PackageFieldMatchOption.StartsWithCaseInsensitive; + filter.Value = query; + findPackagesOptions.Filters.Add(filter); + + // Perform the query (search by command) + var findPackagesResult = catalog.FindPackages(findPackagesOptions); + var matches = findPackagesResult.Matches; + var pkg = TryGetBestMatchingPackage(matches); + if (pkg != null) + { + return pkg; + } + + // No matches found when searching by command, + // let's try again and search by name + filter.Field = PackageMatchField.Name; + filter.Option = PackageFieldMatchOption.ContainsCaseInsensitive; + filter.Value = query; + findPackagesOptions.Filters.Clear(); + findPackagesOptions.Filters.Add(filter); + + // Perform the query (search by name) + findPackagesResult = catalog.FindPackages(findPackagesOptions); + matches = findPackagesResult.Matches; + return TryGetBestMatchingPackage(matches); } - public Guid Id => _guid; - - public string Name => "winget-cmd-not-found-predictor"; - - public string Description => "Predict the install command for missing commands via winget."; - public bool CanAcceptFeedback(PredictionClient client, PredictorFeedbackKind feedback) { return feedback switch @@ -173,12 +255,12 @@ public SuggestionPackage GetSuggestion(PredictionClient client, PredictionContex List? result = null; result ??= new List(1); - if (WingetPrediction is null) + if (_suggestion is null) { return default; } - result.Add(new PredictiveSuggestion(WingetPrediction)); + result.Add(new PredictiveSuggestion(_suggestion)); if (result is not null) { @@ -190,7 +272,7 @@ public SuggestionPackage GetSuggestion(PredictionClient client, PredictionContex public void OnCommandLineAccepted(PredictionClient client, IReadOnlyList history) { - WingetPrediction = null; + _suggestion = null; } public void OnSuggestionDisplayed(PredictionClient client, uint session, int countOrIndex) { } diff --git a/src/WinGetCommandNotFound.csproj b/src/WinGetCommandNotFound.csproj index 2b89720..4314cbb 100644 --- a/src/WinGetCommandNotFound.csproj +++ b/src/WinGetCommandNotFound.csproj @@ -1,33 +1,52 @@ - + - - net7.0 - - enable - enable - false - ..\bin\WinGetCommandNotFound - true - win-arm64;win-x64 - + + net8.0-windows10.0.22621.0 + AnyCPU + + enable + enable + false + ..\bin\WinGetCommandNotFound + true + win-arm64;win-x64 + - - - false - None - + + - - - contentFiles - All - - - - - PreserveNewest - PreserveNewest - - + + Microsoft.Management.Deployment + 10.0.22621.0 + + $(OutDir) + + + + + + + + + false + None + + + + + contentFiles + All + + + + + PreserveNewest + PreserveNewest + + From 974c30b07a5bfc6814c69742073b7851f947d276 Mon Sep 17 00:00:00 2001 From: Carlos Zamora Date: Fri, 24 Mar 2023 10:08:35 -0700 Subject: [PATCH 12/17] normalize line endings --- src/WinGetCommandNotFound.cs | 310 +++++++++++++++++------------------ 1 file changed, 155 insertions(+), 155 deletions(-) diff --git a/src/WinGetCommandNotFound.cs b/src/WinGetCommandNotFound.cs index b5a59d2..5869470 100644 --- a/src/WinGetCommandNotFound.cs +++ b/src/WinGetCommandNotFound.cs @@ -1,13 +1,13 @@ -using System.Diagnostics; -using System.Management.Automation; -using System.Management.Automation.Runspaces; -using System.Management.Automation.Subsystem; -using System.Management.Automation.Subsystem.Feedback; -using System.Management.Automation.Subsystem.Prediction; +using System.Diagnostics; +using System.Management.Automation; +using System.Management.Automation.Runspaces; +using System.Management.Automation.Subsystem; +using System.Management.Automation.Subsystem.Feedback; +using System.Management.Automation.Subsystem.Prediction; using System.Runtime.InteropServices; -using Microsoft.Management.Deployment; - -namespace wingetprovider +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 @@ -77,95 +77,95 @@ public static PackageMatchFilter CreatePackageMatchFilter() { return Create(PackageMatchFilterType, PackageMatchFilterIid); } - } - - 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); - SubsystemManager.RegisterSubsystem(WinGetCommandNotFoundFeedbackPredictor.Singleton); - } - - public void OnRemove(PSModuleInfo psModuleInfo) - { - SubsystemManager.UnregisterSubsystem(new Guid(id)); - SubsystemManager.UnregisterSubsystem(new Guid(id)); - } - } - - public sealed class WinGetCommandNotFoundFeedbackPredictor : IFeedbackProvider, ICommandPredictor - { - private readonly Guid _guid; - private string? _suggestion; - private PackageManager _packageManager; + } + + 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); + SubsystemManager.RegisterSubsystem(WinGetCommandNotFoundFeedbackPredictor.Singleton); + } + + public void OnRemove(PSModuleInfo psModuleInfo) + { + SubsystemManager.UnregisterSubsystem(new Guid(id)); + SubsystemManager.UnregisterSubsystem(new Guid(id)); + } + } + + public sealed class WinGetCommandNotFoundFeedbackPredictor : IFeedbackProvider, ICommandPredictor + { + private readonly Guid _guid; + private string? _suggestion; + private PackageManager _packageManager; Dictionary? ISubsystem.FunctionsToDefine => null; - - public static WinGetCommandNotFoundFeedbackPredictor Singleton { get; } = new WinGetCommandNotFoundFeedbackPredictor(Init.id); - private WinGetCommandNotFoundFeedbackPredictor(string guid) - { - _guid = new Guid(guid); + + public static WinGetCommandNotFoundFeedbackPredictor Singleton { get; } = new WinGetCommandNotFoundFeedbackPredictor(Init.id); + private WinGetCommandNotFoundFeedbackPredictor(string guid) + { + _guid = new Guid(guid); ComObjectFactory.InitializeUndockedRegFreeWinRT(); - _packageManager = ComObjectFactory.CreatePackageManager(); - } - - 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 package = FindPackage(target); - if (package is null) - { - return null; - } - _suggestion = "winget install --id " + package.Id; - return new FeedbackItem( - Name, - new List { _suggestion } - ); - } - return null; - } - + _packageManager = ComObjectFactory.CreatePackageManager(); + } + + 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 package = FindPackage(target); + if (package is null) + { + return null; + } + _suggestion = "winget install --id " + package.Id; + return new FeedbackItem( + Name, + new List { _suggestion } + ); + } + return null; + } + private CatalogPackage? TryGetBestMatchingPackage(IReadOnlyList matches) { if (matches.Count == 1) { // One match --> return the package - return matches.First().CatalogPackage; + return matches.First().CatalogPackage; } else if (matches.Count > 1) { @@ -195,33 +195,33 @@ public void Dispose() bestMatch.CatalogPackage; } return null; - } - - // Adapted from WinGet sample documentation: https://github.com/microsoft/winget-cli/blob/master/doc/specs/%23888%20-%20Com%20Api.md#32-search - private CatalogPackage? FindPackage(string query) - { - // Get the package catalog - var catalogRef = _packageManager.GetPredefinedPackageCatalog(PredefinedPackageCatalog.OpenWindowsCatalog); - var connectResult = catalogRef.Connect(); - if (connectResult.Status != ConnectResultStatus.Ok) + } + + // Adapted from WinGet sample documentation: https://github.com/microsoft/winget-cli/blob/master/doc/specs/%23888%20-%20Com%20Api.md#32-search + private CatalogPackage? FindPackage(string query) + { + // Get the package catalog + var catalogRef = _packageManager.GetPredefinedPackageCatalog(PredefinedPackageCatalog.OpenWindowsCatalog); + var connectResult = catalogRef.Connect(); + if (connectResult.Status != ConnectResultStatus.Ok) { // TODO CARLOS: do better if ConnectResultStatus != Ok (aka CatalogError) - return null; - } - var catalog = connectResult.PackageCatalog; - - // Configure query for the package catalog - var findPackagesOptions = ComObjectFactory.CreateFindPackagesOptions(); + return null; + } + var catalog = connectResult.PackageCatalog; + + // Configure query for the package catalog + var findPackagesOptions = ComObjectFactory.CreateFindPackagesOptions(); var filter = ComObjectFactory.CreatePackageMatchFilter(); filter.Field = PackageMatchField.Command; filter.Option = PackageFieldMatchOption.StartsWithCaseInsensitive; - filter.Value = query; - findPackagesOptions.Filters.Add(filter); - - // Perform the query (search by command) - var findPackagesResult = catalog.FindPackages(findPackagesOptions); - var matches = findPackagesResult.Matches; - var pkg = TryGetBestMatchingPackage(matches); + filter.Value = query; + findPackagesOptions.Filters.Add(filter); + + // Perform the query (search by command) + var findPackagesResult = catalog.FindPackages(findPackagesOptions); + var matches = findPackagesResult.Matches; + var pkg = TryGetBestMatchingPackage(matches); if (pkg != null) { return pkg; @@ -239,46 +239,46 @@ public void Dispose() findPackagesResult = catalog.FindPackages(findPackagesOptions); matches = findPackagesResult.Matches; return TryGetBestMatchingPackage(matches); - } - - public bool CanAcceptFeedback(PredictionClient client, PredictorFeedbackKind feedback) - { - return feedback switch - { - PredictorFeedbackKind.CommandLineAccepted => true, - _ => false, - }; - } - - public SuggestionPackage GetSuggestion(PredictionClient client, PredictionContext context, CancellationToken cancellationToken) - { - List? result = null; - - result ??= new List(1); - if (_suggestion is null) - { - return default; - } - - result.Add(new PredictiveSuggestion(_suggestion)); - - if (result is not null) - { - return new SuggestionPackage(result); - } - - return default; - } - - public void OnCommandLineAccepted(PredictionClient client, IReadOnlyList history) - { - _suggestion = null; - } - - public void OnSuggestionDisplayed(PredictionClient client, uint session, int countOrIndex) { } - - public void OnSuggestionAccepted(PredictionClient client, uint session, string acceptedSuggestion) { } - - public void OnCommandLineExecuted(PredictionClient client, string commandLine, bool success) { } - } + } + + public bool CanAcceptFeedback(PredictionClient client, PredictorFeedbackKind feedback) + { + return feedback switch + { + PredictorFeedbackKind.CommandLineAccepted => true, + _ => false, + }; + } + + public SuggestionPackage GetSuggestion(PredictionClient client, PredictionContext context, CancellationToken cancellationToken) + { + List? result = null; + + result ??= new List(1); + if (_suggestion is null) + { + return default; + } + + result.Add(new PredictiveSuggestion(_suggestion)); + + if (result is not null) + { + return new SuggestionPackage(result); + } + + return default; + } + + public void OnCommandLineAccepted(PredictionClient client, IReadOnlyList history) + { + _suggestion = null; + } + + public void OnSuggestionDisplayed(PredictionClient client, uint session, int countOrIndex) { } + + public void OnSuggestionAccepted(PredictionClient client, uint session, string acceptedSuggestion) { } + + public void OnCommandLineExecuted(PredictionClient client, string commandLine, bool success) { } + } } \ No newline at end of file From 7c69ec819edb81bc4630a526de3fec0482279ff2 Mon Sep 17 00:00:00 2001 From: Carlos Zamora Date: Fri, 24 Mar 2023 11:23:16 -0700 Subject: [PATCH 13/17] resolve TODOs and minor polish --- src/WinGetCommandNotFound.cs | 94 ++++++++++++++++++++---------------- 1 file changed, 52 insertions(+), 42 deletions(-) diff --git a/src/WinGetCommandNotFound.cs b/src/WinGetCommandNotFound.cs index 5869470..28c9a92 100644 --- a/src/WinGetCommandNotFound.cs +++ b/src/WinGetCommandNotFound.cs @@ -1,4 +1,4 @@ -using System.Diagnostics; +using System.Security.Principal; using System.Management.Automation; using System.Management.Automation.Runspaces; using System.Management.Automation.Subsystem; @@ -29,25 +29,29 @@ public class ComObjectFactory 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; - - // TODO CARLOS: Looks like we need a different activation path if we're running as admin - //if (Utilities.ExecutingAsAdministrator) - //{ - // int 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); - //} + 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); @@ -118,6 +122,8 @@ public sealed class WinGetCommandNotFoundFeedbackPredictor : IFeedbackProvider, private readonly Guid _guid; private string? _suggestion; private PackageManager _packageManager; + private FindPackagesOptions _findPackagesOptions; + private PackageMatchFilter _packageMatchFilter; Dictionary? ISubsystem.FunctionsToDefine => null; public static WinGetCommandNotFoundFeedbackPredictor Singleton { get; } = new WinGetCommandNotFoundFeedbackPredictor(Init.id); @@ -126,6 +132,8 @@ private WinGetCommandNotFoundFeedbackPredictor(string guid) _guid = new Guid(guid); ComObjectFactory.InitializeUndockedRegFreeWinRT(); _packageManager = ComObjectFactory.CreatePackageManager(); + _findPackagesOptions = ComObjectFactory.CreateFindPackagesOptions(); + _packageMatchFilter = ComObjectFactory.CreatePackageMatchFilter(); } public void Dispose() @@ -146,7 +154,7 @@ public void Dispose() if (lastError.FullyQualifiedErrorId == "CommandNotFoundException") { var target = (string)lastError.TargetObject; - var package = FindPackage(target); + var package = _FindPackage(target); if (package is null) { return null; @@ -160,7 +168,19 @@ public void Dispose() return null; } - private CatalogPackage? TryGetBestMatchingPackage(IReadOnlyList matches) + private void _ApplyPackageMatchFilter(PackageMatchField field, PackageFieldMatchOption matchOption, string query) + { + // Configure filter + _packageMatchFilter.Field = field; + _packageMatchFilter.Option = matchOption; + _packageMatchFilter.Value = query; + + // Apply filter + _findPackagesOptions.Filters.Clear(); + _findPackagesOptions.Filters.Add(_packageMatchFilter); + } + + private CatalogPackage? _TryGetBestMatchingPackage(IReadOnlyList matches) { if (matches.Count == 1) { @@ -188,40 +208,34 @@ public void Dispose() } } } - // TODO CARLOS: bestMatch == null if only ContainsCaseInsensitive matches exist. - // We should figure out a better way to handle this rather than just returning the first package. + // bestMatch is null iff only ContainsCaseInsensitive matches exist. + // There's no good way to figure out which one is relevant here, so just don't make a suggestion. return bestMatch == null ? - matches.First().CatalogPackage : + null : bestMatch.CatalogPackage; } return null; } // Adapted from WinGet sample documentation: https://github.com/microsoft/winget-cli/blob/master/doc/specs/%23888%20-%20Com%20Api.md#32-search - private CatalogPackage? FindPackage(string query) + private CatalogPackage? _FindPackage(string query) { // Get the package catalog var catalogRef = _packageManager.GetPredefinedPackageCatalog(PredefinedPackageCatalog.OpenWindowsCatalog); var connectResult = catalogRef.Connect(); - if (connectResult.Status != ConnectResultStatus.Ok) + byte retryCount = 0; + while (connectResult.Status != ConnectResultStatus.Ok && retryCount < 3) { - // TODO CARLOS: do better if ConnectResultStatus != Ok (aka CatalogError) - return null; + connectResult = catalogRef.Connect(); + retryCount++; } var catalog = connectResult.PackageCatalog; - // Configure query for the package catalog - var findPackagesOptions = ComObjectFactory.CreateFindPackagesOptions(); - var filter = ComObjectFactory.CreatePackageMatchFilter(); - filter.Field = PackageMatchField.Command; - filter.Option = PackageFieldMatchOption.StartsWithCaseInsensitive; - filter.Value = query; - findPackagesOptions.Filters.Add(filter); - // Perform the query (search by command) - var findPackagesResult = catalog.FindPackages(findPackagesOptions); + _ApplyPackageMatchFilter(PackageMatchField.Command, PackageFieldMatchOption.StartsWithCaseInsensitive, query); + var findPackagesResult = catalog.FindPackages(_findPackagesOptions); var matches = findPackagesResult.Matches; - var pkg = TryGetBestMatchingPackage(matches); + var pkg = _TryGetBestMatchingPackage(matches); if (pkg != null) { return pkg; @@ -229,16 +243,12 @@ public void Dispose() // No matches found when searching by command, // let's try again and search by name - filter.Field = PackageMatchField.Name; - filter.Option = PackageFieldMatchOption.ContainsCaseInsensitive; - filter.Value = query; - findPackagesOptions.Filters.Clear(); - findPackagesOptions.Filters.Add(filter); + _ApplyPackageMatchFilter(PackageMatchField.Name, PackageFieldMatchOption.ContainsCaseInsensitive, query); // Perform the query (search by name) - findPackagesResult = catalog.FindPackages(findPackagesOptions); + findPackagesResult = catalog.FindPackages(_findPackagesOptions); matches = findPackagesResult.Matches; - return TryGetBestMatchingPackage(matches); + return _TryGetBestMatchingPackage(matches); } public bool CanAcceptFeedback(PredictionClient client, PredictorFeedbackKind feedback) From b38eda3d08e3c44c4e9deef2dfdc0a969202b3bc Mon Sep 17 00:00:00 2001 From: Carlos Zamora Date: Thu, 30 Mar 2023 11:00:39 -0700 Subject: [PATCH 14/17] fix string design & multiple result behavior --- src/WinGetCommandNotFound.cs | 145 +++++++++++++++++------------------ 1 file changed, 72 insertions(+), 73 deletions(-) diff --git a/src/WinGetCommandNotFound.cs b/src/WinGetCommandNotFound.cs index 28c9a92..9caac47 100644 --- a/src/WinGetCommandNotFound.cs +++ b/src/WinGetCommandNotFound.cs @@ -107,24 +107,23 @@ public void OnImport() } SubsystemManager.RegisterSubsystem(WinGetCommandNotFoundFeedbackPredictor.Singleton); - SubsystemManager.RegisterSubsystem(WinGetCommandNotFoundFeedbackPredictor.Singleton); } public void OnRemove(PSModuleInfo psModuleInfo) { SubsystemManager.UnregisterSubsystem(new Guid(id)); - SubsystemManager.UnregisterSubsystem(new Guid(id)); } } - public sealed class WinGetCommandNotFoundFeedbackPredictor : IFeedbackProvider, ICommandPredictor + public sealed class WinGetCommandNotFoundFeedbackPredictor : IFeedbackProvider { private readonly Guid _guid; - private string? _suggestion; private PackageManager _packageManager; private FindPackagesOptions _findPackagesOptions; private PackageMatchFilter _packageMatchFilter; - Dictionary? ISubsystem.FunctionsToDefine => null; + private bool _tooManySuggestions; + + private static readonly byte _maxSuggestions = 5; public static WinGetCommandNotFoundFeedbackPredictor Singleton { get; } = new WinGetCommandNotFoundFeedbackPredictor(Init.id); private WinGetCommandNotFoundFeedbackPredictor(string guid) @@ -134,6 +133,7 @@ private WinGetCommandNotFoundFeedbackPredictor(string guid) _packageManager = ComObjectFactory.CreatePackageManager(); _findPackagesOptions = ComObjectFactory.CreateFindPackagesOptions(); _packageMatchFilter = ComObjectFactory.CreatePackageMatchFilter(); + _tooManySuggestions = false; } public void Dispose() @@ -154,15 +154,30 @@ public void Dispose() if (lastError.FullyQualifiedErrorId == "CommandNotFoundException") { var target = (string)lastError.TargetObject; - var package = _FindPackage(target); - if (package is null) + var pkgList = _FindPackages(target); + if (pkgList.Count == 0) { return null; } - _suggestion = "winget install --id " + package.Id; + + // Build list of suggestions + var suggestionList = new List(); + foreach (var pkg in pkgList) + { + suggestionList.Add(String.Format("winget install --id {0} # Version: {1}", pkg.Id, pkg.DefaultInstallVersion.Version)); + } + + // Build footer message + var filterFieldString = _packageMatchFilter.Field == PackageMatchField.Command ? "command" : "name"; + var footerMessage = _tooManySuggestions ? + String.Format("Additional results can be found using \"winget search --{0} {1}\"", filterFieldString, _packageMatchFilter.Value) : + null; + return new FeedbackItem( - Name, - new List { _suggestion } + "Try installing this package using winget:", + suggestionList, + footerMessage, + FeedbackDisplayLayout.Portrait ); } return null; @@ -176,49 +191,73 @@ private void _ApplyPackageMatchFilter(PackageMatchField field, PackageFieldMatch _packageMatchFilter.Value = query; // Apply filter + _findPackagesOptions.ResultLimit = _maxSuggestions + 1u; _findPackagesOptions.Filters.Clear(); _findPackagesOptions.Filters.Add(_packageMatchFilter); } - private CatalogPackage? _TryGetBestMatchingPackage(IReadOnlyList matches) + private List _TryGetBestMatchingPackage(IReadOnlyList matches) { + var results = new List(); if (matches.Count == 1) { // One match --> return the package - return matches.First().CatalogPackage; + results.Add(matches.First().CatalogPackage); } else if (matches.Count > 1) { - // Multiple matches --> return the one with the shortest match that starts with the query - MatchResult? bestMatch = null; + // 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]; - if (match.MatchCriteria.Option == PackageFieldMatchOption.EqualsCaseInsensitive || match.MatchCriteria.Option == PackageFieldMatchOption.Equals) + switch (match.MatchCriteria.Option) { - // Exact match --> return the package - return match.CatalogPackage; + 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; } - else if (match.MatchCriteria.Option == PackageFieldMatchOption.StartsWithCaseInsensitive) + } + + // Now return the top _maxSuggestions + while (results.Count < _maxSuggestions) + { + if (bestExactMatches.Count > 0) { - // get the shortest match that starts with the query - if (bestMatch == null || match.MatchCriteria.Value.Length < bestMatch.MatchCriteria.Value.Length) - { - bestMatch = match; - } + 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; } } - // bestMatch is null iff only ContainsCaseInsensitive matches exist. - // There's no good way to figure out which one is relevant here, so just don't make a suggestion. - return bestMatch == null ? - null : - bestMatch.CatalogPackage; } - return null; + _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 CatalogPackage? _FindPackage(string query) + private List _FindPackages(string query) { // Get the package catalog var catalogRef = _packageManager.GetPredefinedPackageCatalog(PredefinedPackageCatalog.OpenWindowsCatalog); @@ -235,10 +274,10 @@ private void _ApplyPackageMatchFilter(PackageMatchField field, PackageFieldMatch _ApplyPackageMatchFilter(PackageMatchField.Command, PackageFieldMatchOption.StartsWithCaseInsensitive, query); var findPackagesResult = catalog.FindPackages(_findPackagesOptions); var matches = findPackagesResult.Matches; - var pkg = _TryGetBestMatchingPackage(matches); - if (pkg != null) + var pkgList = _TryGetBestMatchingPackage(matches); + if (pkgList.Count > 0) { - return pkg; + return pkgList; } // No matches found when searching by command, @@ -250,45 +289,5 @@ private void _ApplyPackageMatchFilter(PackageMatchField field, PackageFieldMatch matches = findPackagesResult.Matches; return _TryGetBestMatchingPackage(matches); } - - public bool CanAcceptFeedback(PredictionClient client, PredictorFeedbackKind feedback) - { - return feedback switch - { - PredictorFeedbackKind.CommandLineAccepted => true, - _ => false, - }; - } - - public SuggestionPackage GetSuggestion(PredictionClient client, PredictionContext context, CancellationToken cancellationToken) - { - List? result = null; - - result ??= new List(1); - if (_suggestion is null) - { - return default; - } - - result.Add(new PredictiveSuggestion(_suggestion)); - - if (result is not null) - { - return new SuggestionPackage(result); - } - - return default; - } - - public void OnCommandLineAccepted(PredictionClient client, IReadOnlyList history) - { - _suggestion = null; - } - - public void OnSuggestionDisplayed(PredictionClient client, uint session, int countOrIndex) { } - - public void OnSuggestionAccepted(PredictionClient client, uint session, string acceptedSuggestion) { } - - public void OnCommandLineExecuted(PredictionClient client, string commandLine, bool success) { } } } \ No newline at end of file From fdbd56b6629355b9c38ba060ff3aae0a7bdbbe25 Mon Sep 17 00:00:00 2001 From: Carlos Zamora Date: Thu, 30 Mar 2023 14:59:31 -0700 Subject: [PATCH 15/17] use singleton for WinGet; remove version --- src/WinGetCommandNotFound.cs | 56 ++++++++++++++++++++++++------------ 1 file changed, 37 insertions(+), 19 deletions(-) diff --git a/src/WinGetCommandNotFound.cs b/src/WinGetCommandNotFound.cs index 9caac47..0551903 100644 --- a/src/WinGetCommandNotFound.cs +++ b/src/WinGetCommandNotFound.cs @@ -83,6 +83,31 @@ public static PackageMatchFilter CreatePackageMatchFilter() } } + public sealed class WinGetComObjects + { + private static readonly WinGetComObjects instance = new WinGetComObjects(); + + private WinGetComObjects() + { + ComObjectFactory.InitializeUndockedRegFreeWinRT(); + packageManager = ComObjectFactory.CreatePackageManager(); + findPackagesOptions = ComObjectFactory.CreateFindPackagesOptions(); + packageMatchFilter = ComObjectFactory.CreatePackageMatchFilter(); + } + + public static WinGetComObjects Instance + { + get + { + return instance; + } + } + + 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"; @@ -118,9 +143,6 @@ public void OnRemove(PSModuleInfo psModuleInfo) public sealed class WinGetCommandNotFoundFeedbackPredictor : IFeedbackProvider { private readonly Guid _guid; - private PackageManager _packageManager; - private FindPackagesOptions _findPackagesOptions; - private PackageMatchFilter _packageMatchFilter; private bool _tooManySuggestions; private static readonly byte _maxSuggestions = 5; @@ -129,10 +151,6 @@ public sealed class WinGetCommandNotFoundFeedbackPredictor : IFeedbackProvider private WinGetCommandNotFoundFeedbackPredictor(string guid) { _guid = new Guid(guid); - ComObjectFactory.InitializeUndockedRegFreeWinRT(); - _packageManager = ComObjectFactory.CreatePackageManager(); - _findPackagesOptions = ComObjectFactory.CreateFindPackagesOptions(); - _packageMatchFilter = ComObjectFactory.CreatePackageMatchFilter(); _tooManySuggestions = false; } @@ -164,13 +182,13 @@ public void Dispose() var suggestionList = new List(); foreach (var pkg in pkgList) { - suggestionList.Add(String.Format("winget install --id {0} # Version: {1}", pkg.Id, pkg.DefaultInstallVersion.Version)); + suggestionList.Add(String.Format("winget install --id {0}", pkg.Id)); } // Build footer message - var filterFieldString = _packageMatchFilter.Field == PackageMatchField.Command ? "command" : "name"; + var filterFieldString = WinGetComObjects.Instance.packageMatchFilter.Field == PackageMatchField.Command ? "command" : "name"; var footerMessage = _tooManySuggestions ? - String.Format("Additional results can be found using \"winget search --{0} {1}\"", filterFieldString, _packageMatchFilter.Value) : + String.Format("Additional results can be found using \"winget search --{0} {1}\"", filterFieldString, WinGetComObjects.Instance.packageMatchFilter.Value) : null; return new FeedbackItem( @@ -186,14 +204,14 @@ public void Dispose() private void _ApplyPackageMatchFilter(PackageMatchField field, PackageFieldMatchOption matchOption, string query) { // Configure filter - _packageMatchFilter.Field = field; - _packageMatchFilter.Option = matchOption; - _packageMatchFilter.Value = query; + WinGetComObjects.Instance.packageMatchFilter.Field = field; + WinGetComObjects.Instance.packageMatchFilter.Option = matchOption; + WinGetComObjects.Instance.packageMatchFilter.Value = query; // Apply filter - _findPackagesOptions.ResultLimit = _maxSuggestions + 1u; - _findPackagesOptions.Filters.Clear(); - _findPackagesOptions.Filters.Add(_packageMatchFilter); + WinGetComObjects.Instance.findPackagesOptions.ResultLimit = _maxSuggestions + 1u; + WinGetComObjects.Instance.findPackagesOptions.Filters.Clear(); + WinGetComObjects.Instance.findPackagesOptions.Filters.Add(WinGetComObjects.Instance.packageMatchFilter); } private List _TryGetBestMatchingPackage(IReadOnlyList matches) @@ -260,7 +278,7 @@ private List _TryGetBestMatchingPackage(IReadOnlyList _FindPackages(string query) { // Get the package catalog - var catalogRef = _packageManager.GetPredefinedPackageCatalog(PredefinedPackageCatalog.OpenWindowsCatalog); + var catalogRef = WinGetComObjects.Instance.packageManager.GetPredefinedPackageCatalog(PredefinedPackageCatalog.OpenWindowsCatalog); var connectResult = catalogRef.Connect(); byte retryCount = 0; while (connectResult.Status != ConnectResultStatus.Ok && retryCount < 3) @@ -272,7 +290,7 @@ private List _FindPackages(string query) // Perform the query (search by command) _ApplyPackageMatchFilter(PackageMatchField.Command, PackageFieldMatchOption.StartsWithCaseInsensitive, query); - var findPackagesResult = catalog.FindPackages(_findPackagesOptions); + var findPackagesResult = catalog.FindPackages(WinGetComObjects.Instance.findPackagesOptions); var matches = findPackagesResult.Matches; var pkgList = _TryGetBestMatchingPackage(matches); if (pkgList.Count > 0) @@ -285,7 +303,7 @@ private List _FindPackages(string query) _ApplyPackageMatchFilter(PackageMatchField.Name, PackageFieldMatchOption.ContainsCaseInsensitive, query); // Perform the query (search by name) - findPackagesResult = catalog.FindPackages(_findPackagesOptions); + findPackagesResult = catalog.FindPackages(WinGetComObjects.Instance.findPackagesOptions); matches = findPackagesResult.Matches; return _TryGetBestMatchingPackage(matches); } From 0b843401011add17f06954f1eaf3f41a5bf363d7 Mon Sep 17 00:00:00 2001 From: Carlos Zamora Date: Fri, 31 Mar 2023 13:23:35 -0700 Subject: [PATCH 16/17] polish --- src/WinGetCommandNotFound.cs | 33 ++++++++++++--------------------- 1 file changed, 12 insertions(+), 21 deletions(-) diff --git a/src/WinGetCommandNotFound.cs b/src/WinGetCommandNotFound.cs index 0551903..a51ed63 100644 --- a/src/WinGetCommandNotFound.cs +++ b/src/WinGetCommandNotFound.cs @@ -3,7 +3,6 @@ using System.Management.Automation.Runspaces; using System.Management.Automation.Subsystem; using System.Management.Automation.Subsystem.Feedback; -using System.Management.Automation.Subsystem.Prediction; using System.Runtime.InteropServices; using Microsoft.Management.Deployment; @@ -85,7 +84,7 @@ public static PackageMatchFilter CreatePackageMatchFilter() public sealed class WinGetComObjects { - private static readonly WinGetComObjects instance = new WinGetComObjects(); + public static WinGetComObjects Singleton { get; } = new WinGetComObjects(); private WinGetComObjects() { @@ -94,14 +93,6 @@ private WinGetComObjects() findPackagesOptions = ComObjectFactory.CreateFindPackagesOptions(); packageMatchFilter = ComObjectFactory.CreatePackageMatchFilter(); } - - public static WinGetComObjects Instance - { - get - { - return instance; - } - } public PackageManager packageManager { get; } public FindPackagesOptions findPackagesOptions { get; } @@ -186,9 +177,9 @@ public void Dispose() } // Build footer message - var filterFieldString = WinGetComObjects.Instance.packageMatchFilter.Field == PackageMatchField.Command ? "command" : "name"; + 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.Instance.packageMatchFilter.Value) : + String.Format("Additional results can be found using \"winget search --{0} {1}\"", filterFieldString, WinGetComObjects.Singleton.packageMatchFilter.Value) : null; return new FeedbackItem( @@ -204,14 +195,14 @@ public void Dispose() private void _ApplyPackageMatchFilter(PackageMatchField field, PackageFieldMatchOption matchOption, string query) { // Configure filter - WinGetComObjects.Instance.packageMatchFilter.Field = field; - WinGetComObjects.Instance.packageMatchFilter.Option = matchOption; - WinGetComObjects.Instance.packageMatchFilter.Value = query; + WinGetComObjects.Singleton.packageMatchFilter.Field = field; + WinGetComObjects.Singleton.packageMatchFilter.Option = matchOption; + WinGetComObjects.Singleton.packageMatchFilter.Value = query; // Apply filter - WinGetComObjects.Instance.findPackagesOptions.ResultLimit = _maxSuggestions + 1u; - WinGetComObjects.Instance.findPackagesOptions.Filters.Clear(); - WinGetComObjects.Instance.findPackagesOptions.Filters.Add(WinGetComObjects.Instance.packageMatchFilter); + WinGetComObjects.Singleton.findPackagesOptions.ResultLimit = _maxSuggestions + 1u; + WinGetComObjects.Singleton.findPackagesOptions.Filters.Clear(); + WinGetComObjects.Singleton.findPackagesOptions.Filters.Add(WinGetComObjects.Singleton.packageMatchFilter); } private List _TryGetBestMatchingPackage(IReadOnlyList matches) @@ -278,7 +269,7 @@ private List _TryGetBestMatchingPackage(IReadOnlyList _FindPackages(string query) { // Get the package catalog - var catalogRef = WinGetComObjects.Instance.packageManager.GetPredefinedPackageCatalog(PredefinedPackageCatalog.OpenWindowsCatalog); + var catalogRef = WinGetComObjects.Singleton.packageManager.GetPredefinedPackageCatalog(PredefinedPackageCatalog.OpenWindowsCatalog); var connectResult = catalogRef.Connect(); byte retryCount = 0; while (connectResult.Status != ConnectResultStatus.Ok && retryCount < 3) @@ -290,7 +281,7 @@ private List _FindPackages(string query) // Perform the query (search by command) _ApplyPackageMatchFilter(PackageMatchField.Command, PackageFieldMatchOption.StartsWithCaseInsensitive, query); - var findPackagesResult = catalog.FindPackages(WinGetComObjects.Instance.findPackagesOptions); + var findPackagesResult = catalog.FindPackages(WinGetComObjects.Singleton.findPackagesOptions); var matches = findPackagesResult.Matches; var pkgList = _TryGetBestMatchingPackage(matches); if (pkgList.Count > 0) @@ -303,7 +294,7 @@ private List _FindPackages(string query) _ApplyPackageMatchFilter(PackageMatchField.Name, PackageFieldMatchOption.ContainsCaseInsensitive, query); // Perform the query (search by name) - findPackagesResult = catalog.FindPackages(WinGetComObjects.Instance.findPackagesOptions); + findPackagesResult = catalog.FindPackages(WinGetComObjects.Singleton.findPackagesOptions); matches = findPackagesResult.Matches; return _TryGetBestMatchingPackage(matches); } From 369da3f03b7ed17aa8b84a318db606fb49ab2c8f Mon Sep 17 00:00:00 2001 From: Carlos Zamora Date: Fri, 31 Mar 2023 14:52:23 -0700 Subject: [PATCH 17/17] polish before making a PR --- README.md | 25 ++++--------------------- src/WinGetCommandNotFound.csproj | 9 --------- src/WinGetCommandNotFound.psd1 | 2 +- 3 files changed, 5 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 171ce6c..33d489d 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,16 @@ # WinGetCommandNotFound -This is a proof-of-concept implementing both the `IFeedbackProvider` and `ICommandPredictor` interfaces. +This is a proof-of-concept implementing both the `IFeedbackProvider` interfaces. `IFeedbackProvider` requires PS7.4+. -**This is NOT intended to be used outside of a demo with no intent to take this to production** - -![Screen-Recording-2022-12-27-at-8](https://user-images.githubusercontent.com/11859881/209662484-c739d16b-3dbd-44be-84b5-2402bcfadbbe.gif) - ## Feedback provider -The feedback provider uses the existing sqlite database used by winget instead of downloading and expanding -its own copy. - -Because the `WindowsApps` folder is protected, this implementation currently uses a hardcoded path to the -`index.db` file in "$env:ProgramFiles\WindowsApps\Microsoft.Winget.Source_2022.1227.1114.286_neutral__8wekyb3d8bbwe\public\index.db". -A different version of Winget will use a different path. - -I've only tested this on win-arm64, but it builds for win-x64 runtime, so it should work. - -## Command predictor - -PSReadLine currently does not have a way to present a prediction without a keypress. -The suggestion from the feedback provider is given as a prediction, but will require pressing `w` for the prediction to show. +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 7 SDK installed and in path. +Go to `src` folder and use `dotnet build`. Requires .NET 8 SDK installed and in path. ## Using -In the published folder, just `Import-Module WinGetCommandNotFound.psd1` which will register the Feedback Provider and Predictor -Then type a command you don't have installed. +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/src/WinGetCommandNotFound.csproj b/src/WinGetCommandNotFound.csproj index 4314cbb..22ebc18 100644 --- a/src/WinGetCommandNotFound.csproj +++ b/src/WinGetCommandNotFound.csproj @@ -3,7 +3,6 @@ net8.0-windows10.0.22621.0 AnyCPU - enable enable false @@ -12,17 +11,9 @@ win-arm64;win-x64 - - - Microsoft.Management.Deployment 10.0.22621.0 - $(OutDir) diff --git a/src/WinGetCommandNotFound.psd1 b/src/WinGetCommandNotFound.psd1 index 3a502f5..0d273fd 100644 --- a/src/WinGetCommandNotFound.psd1 +++ b/src/WinGetCommandNotFound.psd1 @@ -1,7 +1,7 @@ @{ ModuleVersion = '0.1.0' GUID = '28c9afa2-92e5-413e-8e53-44b2d7a83ac6' - Author = 'Steve Lee' + Author = 'Carlos Zamora' CompanyName = "Microsoft Corporation" Copyright = "Copyright (c) Microsoft Corporation." Description = 'Enable suggestions on how to install missing commands via winget'