diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..3b64687c --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: "nuget" + directory: "/" # Location of package manifests + schedule: + interval: "weekly" \ No newline at end of file diff --git a/RemoteDebuggerLauncherExtension.sln.GhostDoc.user.dic b/RemoteDebuggerLauncherExtension.sln.GhostDoc.user.dic index 6f198c9a..9126dd8f 100644 --- a/RemoteDebuggerLauncherExtension.sln.GhostDoc.user.dic +++ b/RemoteDebuggerLauncherExtension.sln.GhostDoc.user.dic @@ -38,6 +38,7 @@ nuget Offroad omnisharp openssh +Passphrase pkgdef plugin powershell @@ -57,6 +58,7 @@ stdout sudo Thumbprint typeof +Unadvise uname unconfigured Username diff --git a/src/Extension/RemoteDebuggerLauncher/Commands/SetupHttpsCommand.cs b/src/Extension/RemoteDebuggerLauncher/Commands/SetupHttpsCommand.cs index 0a382c74..3262dd78 100644 --- a/src/Extension/RemoteDebuggerLauncher/Commands/SetupHttpsCommand.cs +++ b/src/Extension/RemoteDebuggerLauncher/Commands/SetupHttpsCommand.cs @@ -7,12 +7,8 @@ using System; using System.ComponentModel.Design; -using System.Globalization; -using System.Threading; using System.Threading.Tasks; using Microsoft.VisualStudio.Shell; -using Microsoft.VisualStudio.Shell.Interop; -using RemoteDebuggerLauncher.RemoteOperations; using RemoteDebuggerLauncher.WebTools; namespace RemoteDebuggerLauncher @@ -25,9 +21,6 @@ internal sealed class SetupHttpsCommand /// Command ID. public const int CommandId = 0x0105; - /// VS Package that provides this command, not null. - public static readonly Guid CommandSet = new Guid("67dde3fd-abea-469b-939f-02a3178c91e7"); - /// /// VS Package that provides this command, not null. /// diff --git a/src/Extension/RemoteDebuggerLauncher/GlobalSuppressions.cs b/src/Extension/RemoteDebuggerLauncher/GlobalSuppressions.cs index b4fe5168..56366ac4 100644 --- a/src/Extension/RemoteDebuggerLauncher/GlobalSuppressions.cs +++ b/src/Extension/RemoteDebuggerLauncher/GlobalSuppressions.cs @@ -11,21 +11,16 @@ using System.Diagnostics.CodeAnalysis; [assembly: SuppressMessage("Design", "CA1068:CancellationToken parameters must come last", Justification = "Signature given by VS Package", Scope = "member", Target = "~M:RemoteDebuggerLauncher.RemoteDebuggerLauncherPackage.CreateServiceAsync(Microsoft.VisualStudio.Shell.IAsyncServiceContainer,System.Threading.CancellationToken,System.Type)~System.Threading.Tasks.Task{System.Object}")] -[assembly: SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "Needs to be an instance property", Scope = "member", Target = "~P:RemoteDebuggerLauncher.AdapterLaunchConfiguration.LaunchConfiguration.Type")] -[assembly: SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "Needs to be an instance property", Scope = "member", Target = "~P:RemoteDebuggerLauncher.AdapterLaunchConfiguration.LaunchConfiguration.Console")] -[assembly: SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "Needs to be an instance property", Scope = "member", Target = "~P:RemoteDebuggerLauncher.AdapterLaunchConfiguration.LaunchConfiguration.Version")] -[assembly: SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "Needs to be an instance property", Scope = "member", Target = "~P:RemoteDebuggerLauncher.AdapterLaunchConfiguration.LaunchConfiguration.Request")] [assembly: SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "required in order to safely ignore errors", Scope = "member", Target = "~P:RemoteDebuggerLauncher.WebTools.ConfiguredWebProject.WebRoot")] [assembly: SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "required in order to safely ignore errors", Scope = "member", Target = "~M:RemoteDebuggerLauncher.WebTools.StaticWebAssetsCollectorService.LoadStaticAssetsAsync~System.Threading.Tasks.Task")] -[assembly: SuppressMessage("Minor Code Smell", "S1075:URIs should not be hard coded", Justification = "By design, is a well-known URL", Scope = "member", Target = "~F:RemoteDebuggerLauncher.PackageConstants.Dotnet.GetInstallDotnetShUrl")] -[assembly: SuppressMessage("Minor Code Smell", "S1075:URIs should not be hard coded", Justification = "By design, is a well-known URL", Scope = "member", Target = "~F:RemoteDebuggerLauncher.PackageConstants.Dotnet.GetInstallDotnetPs1Url")] -[assembly: SuppressMessage("Minor Code Smell", "S1075:URIs should not be hard coded", Justification = "By design, is a well-known URL", Scope = "member", Target = "~F:RemoteDebuggerLauncher.PackageConstants.Debugger.GetVsDbgPs1Url")] -[assembly: SuppressMessage("Minor Code Smell", "S1075:URIs should not be hard coded", Justification = "By design, is a well-known URL", Scope = "member", Target = "~F:RemoteDebuggerLauncher.PackageConstants.Debugger.GetVsDbgShUrl")] -[assembly: SuppressMessage("Major Code Smell", "S3881:\"IDisposable\" should be implemented correctly", Justification = "Dispose used to control the lifetime of the instance only.", Scope = "type", Target = "~T:RemoteDebuggerLauncher.WaitDialogService")] -[assembly: SuppressMessage("Minor Code Smell", "S101:Types should be named in PascalCase", Justification = "By design, is a well-known term", Scope = "type", Target = "~T:RemoteDebuggerLauncher.PackageConstants.CPS")] -[assembly: SuppressMessage("Minor Code Smell", "S3267:Loops should be simplified with \"LINQ\" expressions", Justification = "By design", Scope = "member", Target = "~M:RemoteDebuggerLauncher.DotnetPublishService.SupportsFrameworkDependentAsync~System.Threading.Tasks.Task{System.Boolean}")] [assembly: SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Required to not crash the process on an exception", Scope = "member", Target = "~M:RemoteDebuggerLauncher.DotnetPublishService.StartAsync~System.Threading.Tasks.Task")] [assembly: SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "By design to ignore all failures", Scope = "member", Target = "~M:RemoteDebuggerLauncher.WebTools.CertificateService.DisposeCertificates(System.Security.Cryptography.X509Certificates.X509Certificate2Collection)")] +[assembly: SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "By design to ignore all failures", Scope = "member", Target = "~M:RemoteDebuggerLauncher.RemoteOperations.SecureShellRemoteBulkCopyRsyncSessionService.StartRsyncDirectorySessionAsync(System.String,System.String,RemoteDebuggerLauncher.IOutputPaneWriterService)~System.Threading.Tasks.Task")] +[assembly: SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "By design to ignore all failures", Scope = "member", Target = "~M:RemoteDebuggerLauncher.RemoteOperations.SecureShellRemoteBulkCopyRsyncSessionService.StartRsyncFileSessionAsync(System.String,System.String,RemoteDebuggerLauncher.IOutputPaneWriterService)~System.Threading.Tasks.Task")] +[assembly: SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "Needs to be an instance property", Scope = "member", Target = "~P:RemoteDebuggerLauncher.AdapterLaunchConfiguration.LaunchConfiguration.Type")] +[assembly: SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "Needs to be an instance property", Scope = "member", Target = "~P:RemoteDebuggerLauncher.AdapterLaunchConfiguration.LaunchConfiguration.Console")] +[assembly: SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "Needs to be an instance property", Scope = "member", Target = "~P:RemoteDebuggerLauncher.AdapterLaunchConfiguration.LaunchConfiguration.Version")] +[assembly: SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "Needs to be an instance property", Scope = "member", Target = "~P:RemoteDebuggerLauncher.AdapterLaunchConfiguration.LaunchConfiguration.Request")] [assembly: SuppressMessage("CodeQuality", "IDE0079:Remove unnecessary suppression", Justification = "False positive", Scope = "member", Target = "~M:RemoteDebuggerLauncher.DotnetPublishService.StartAsync~System.Threading.Tasks.Task")] [assembly: SuppressMessage("CodeQuality", "IDE0079:Remove unnecessary suppression", Justification = "False positive", Scope = "member", Target = "~M:RemoteDebuggerLauncher.RemoteOperations.SecureShellSessionService.UploadFolderRecursiveAsync(System.String,System.String,RemoteDebuggerLauncher.IOutputPaneWriterService)~System.Threading.Tasks.Task")] [assembly: SuppressMessage("CodeQuality", "IDE0079:Remove unnecessary suppression", Justification = "False positive", Scope = "member", Target = "~M:RemoteDebuggerLauncher.RemoteOperations.SecureShellSessionService.UploadFileAsync(System.String,System.String,RemoteDebuggerLauncher.IOutputPaneWriterService)~System.Threading.Tasks.Task")] @@ -37,5 +32,14 @@ [assembly: SuppressMessage("CodeQuality", "IDE0079:Remove unnecessary suppression", Justification = "False positive", Scope = "member", Target = "~M:RemoteDebuggerLauncher.RemoteOperations.SecureShellCopyProgressReporter.OnUploadFile(System.Object,Renci.SshNet.Common.ScpUploadEventArgs)")] [assembly: SuppressMessage("CodeQuality", "IDE0079:Remove unnecessary suppression", Justification = "False positive", Scope = "member", Target = "~M:RemoteDebuggerLauncher.RemoteOperations.SecureShellRemoteBulkCopyRsyncSessionService.StartRsyncDirectorySessionAsync(System.String,System.String,RemoteDebuggerLauncher.IOutputPaneWriterService)~System.Threading.Tasks.Task")] [assembly: SuppressMessage("CodeQuality", "IDE0079:Remove unnecessary suppression", Justification = "False positive", Scope = "member", Target = "~M:RemoteDebuggerLauncher.RemoteOperations.SecureShellRemoteBulkCopyRsyncSessionService.StartRsyncFileSessionAsync(System.String,System.String,RemoteDebuggerLauncher.IOutputPaneWriterService)~System.Threading.Tasks.Task")] -[assembly: SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "By design to ignore all failures", Scope = "member", Target = "~M:RemoteDebuggerLauncher.RemoteOperations.SecureShellRemoteBulkCopyRsyncSessionService.StartRsyncDirectorySessionAsync(System.String,System.String,RemoteDebuggerLauncher.IOutputPaneWriterService)~System.Threading.Tasks.Task")] -[assembly: SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "By design to ignore all failures", Scope = "member", Target = "~M:RemoteDebuggerLauncher.RemoteOperations.SecureShellRemoteBulkCopyRsyncSessionService.StartRsyncFileSessionAsync(System.String,System.String,RemoteDebuggerLauncher.IOutputPaneWriterService)~System.Threading.Tasks.Task")] +[assembly: SuppressMessage("Naming", "CA1713:Events should not have 'Before' or 'After' prefix", Justification = "Keep consistent with VS event sink", Scope = "member", Target = "~E:RemoteDebuggerLauncher.VsSolutionEventsListener.AfterCloseSolution")] +[assembly: SuppressMessage("Naming", "CA1713:Events should not have 'Before' or 'After' prefix", Justification = "Keep consistent with VS event sink", Scope = "member", Target = "~E:RemoteDebuggerLauncher.IVsSolutionEventsFacade.AfterCloseSolution")] +[assembly: SuppressMessage("Major Code Smell", "S1168:Empty arrays and collections should be returned instead of null", Justification = "To prevent unneeded allocations", Scope = "member", Target = "~M:RemoteDebuggerLauncher.RemoteOperations.SecureShellKeyPassphraseService.EncryptPassphrase(System.String)~System.Byte[]")] +[assembly: SuppressMessage("Major Code Smell", "S3881:\"IDisposable\" should be implemented correctly", Justification = "No need", Scope = "type", Target = "~T:RemoteDebuggerLauncher.VsSolutionEventsListener")] +[assembly: SuppressMessage("Minor Code Smell", "S1075:URIs should not be hard coded", Justification = "By design, is a well-known URL", Scope = "member", Target = "~F:RemoteDebuggerLauncher.PackageConstants.Dotnet.GetInstallDotnetShUrl")] +[assembly: SuppressMessage("Minor Code Smell", "S1075:URIs should not be hard coded", Justification = "By design, is a well-known URL", Scope = "member", Target = "~F:RemoteDebuggerLauncher.PackageConstants.Dotnet.GetInstallDotnetPs1Url")] +[assembly: SuppressMessage("Minor Code Smell", "S1075:URIs should not be hard coded", Justification = "By design, is a well-known URL", Scope = "member", Target = "~F:RemoteDebuggerLauncher.PackageConstants.Debugger.GetVsDbgPs1Url")] +[assembly: SuppressMessage("Minor Code Smell", "S1075:URIs should not be hard coded", Justification = "By design, is a well-known URL", Scope = "member", Target = "~F:RemoteDebuggerLauncher.PackageConstants.Debugger.GetVsDbgShUrl")] +[assembly: SuppressMessage("Major Code Smell", "S3881:\"IDisposable\" should be implemented correctly", Justification = "Dispose used to control the lifetime of the instance only.", Scope = "type", Target = "~T:RemoteDebuggerLauncher.WaitDialogService")] +[assembly: SuppressMessage("Minor Code Smell", "S101:Types should be named in PascalCase", Justification = "By design, is a well-known term", Scope = "type", Target = "~T:RemoteDebuggerLauncher.PackageConstants.CPS")] +[assembly: SuppressMessage("Minor Code Smell", "S3267:Loops should be simplified with \"LINQ\" expressions", Justification = "By design", Scope = "member", Target = "~M:RemoteDebuggerLauncher.DotnetPublishService.SupportsFrameworkDependentAsync~System.Threading.Tasks.Task{System.Boolean}")] diff --git a/src/Extension/RemoteDebuggerLauncher/ProjectSystem/ConfiguredPackageServiceFactory.cs b/src/Extension/RemoteDebuggerLauncher/ProjectSystem/ConfiguredPackageServiceFactory.cs index d8172a51..97719a8f 100644 --- a/src/Extension/RemoteDebuggerLauncher/ProjectSystem/ConfiguredPackageServiceFactory.cs +++ b/src/Extension/RemoteDebuggerLauncher/ProjectSystem/ConfiguredPackageServiceFactory.cs @@ -11,6 +11,7 @@ using Microsoft.VisualStudio.ProjectSystem; using Microsoft.VisualStudio.ProjectSystem.Debug; using Microsoft.VisualStudio.Shell; +using RemoteDebuggerLauncher.RemoteOperations; namespace RemoteDebuggerLauncher { @@ -25,8 +26,8 @@ internal sealed class ConfiguredPackageServiceFactory : PackageServiceFactory, I private readonly IDebugTokenReplacer tokenReplacer; [ImportingConstructor] - public ConfiguredPackageServiceFactory(SVsServiceProvider asyncServiceProvider, IVsFacadeFactory facadeFactory, IDebugTokenReplacer tokenReplacer, ConfiguredProject configuredProject) : - base (asyncServiceProvider, facadeFactory, configuredProject) + public ConfiguredPackageServiceFactory(SVsServiceProvider asyncServiceProvider, IVsFacadeFactory facadeFactory, IDebugTokenReplacer tokenReplacer, ConfiguredProject configuredProject, ISecureShellKeyPassphraseService passphraseService) : + base (asyncServiceProvider, facadeFactory, configuredProject, passphraseService) { this.tokenReplacer = tokenReplacer; } diff --git a/src/Extension/RemoteDebuggerLauncher/ProjectSystem/PackageServiceFactory.cs b/src/Extension/RemoteDebuggerLauncher/ProjectSystem/PackageServiceFactory.cs index d47efb17..1412ea38 100644 --- a/src/Extension/RemoteDebuggerLauncher/ProjectSystem/PackageServiceFactory.cs +++ b/src/Extension/RemoteDebuggerLauncher/ProjectSystem/PackageServiceFactory.cs @@ -25,12 +25,14 @@ internal class PackageServiceFactory : IPackageServiceFactory private ConfigurationAggregator configurationAggregator; private ConfiguredProject configuredProject; private IOutputPaneWriterService outputPaneWriter; + private readonly ISecureShellKeyPassphraseService passphraseService; - protected PackageServiceFactory(SVsServiceProvider asyncServiceProvider, IVsFacadeFactory facadeFactory, ConfiguredProject configuredProject) + protected PackageServiceFactory(SVsServiceProvider asyncServiceProvider, IVsFacadeFactory facadeFactory, ConfiguredProject configuredProject, ISecureShellKeyPassphraseService passphraseService) { this.asyncServiceProvider = asyncServiceProvider as IAsyncServiceProvider; this.facadeFactory = facadeFactory; this.configuredProject = configuredProject; + this.passphraseService = passphraseService; } /// @@ -65,7 +67,7 @@ public async Task GetSecureShellRemoteOpera { var statusbar = await GetStatusbarServiceAsync(); var settings = SecureShellSessionSettings.Create(configurationAggregator); - var sessionService = new SecureShellSessionService(settings); + var sessionService = new SecureShellSessionService(settings, passphraseService); var bulkCopyService = CreateBulkCopyService(sessionService, configurationAggregator); return new SecureShellRemoteOperationsService(configurationAggregator, sessionService, bulkCopyService, outputPaneWriter, statusbar); } diff --git a/src/Extension/RemoteDebuggerLauncher/ProjectSystem/UnconfiguredPackageServiceFactory.cs b/src/Extension/RemoteDebuggerLauncher/ProjectSystem/UnconfiguredPackageServiceFactory.cs index 65c19012..a6e6d4d1 100644 --- a/src/Extension/RemoteDebuggerLauncher/ProjectSystem/UnconfiguredPackageServiceFactory.cs +++ b/src/Extension/RemoteDebuggerLauncher/ProjectSystem/UnconfiguredPackageServiceFactory.cs @@ -11,6 +11,7 @@ using Microsoft.VisualStudio.ProjectSystem; using Microsoft.VisualStudio.ProjectSystem.Debug; using Microsoft.VisualStudio.Shell; +using RemoteDebuggerLauncher.RemoteOperations; namespace RemoteDebuggerLauncher { @@ -26,8 +27,8 @@ internal class UnconfiguredPackageServiceFactory : PackageServiceFactory, IUncon private readonly IDebugTokenReplacer tokenReplacer; [ImportingConstructor] - public UnconfiguredPackageServiceFactory(SVsServiceProvider asyncServiceProvider, IDebugTokenReplacer tokenReplacer, IVsFacadeFactory facadeFactory) : - base(asyncServiceProvider, facadeFactory, null) + public UnconfiguredPackageServiceFactory(SVsServiceProvider asyncServiceProvider, IDebugTokenReplacer tokenReplacer, IVsFacadeFactory facadeFactory, ISecureShellKeyPassphraseService passphraseService) : + base(asyncServiceProvider, facadeFactory, null, passphraseService) { this.tokenReplacer = tokenReplacer; } diff --git a/src/Extension/RemoteDebuggerLauncher/ProjectSystem/UnconfiguredProjectExtensions.cs b/src/Extension/RemoteDebuggerLauncher/ProjectSystem/UnconfiguredProjectExtensions.cs index ffdc3fcb..70c6bea9 100644 --- a/src/Extension/RemoteDebuggerLauncher/ProjectSystem/UnconfiguredProjectExtensions.cs +++ b/src/Extension/RemoteDebuggerLauncher/ProjectSystem/UnconfiguredProjectExtensions.cs @@ -7,7 +7,6 @@ using System.IO; using Microsoft.VisualStudio.ProjectSystem; -using Microsoft.VisualStudio.ProjectSystem.Debug; namespace RemoteDebuggerLauncher { diff --git a/src/Extension/RemoteDebuggerLauncher/RemoteDebuggerLauncher.csproj b/src/Extension/RemoteDebuggerLauncher/RemoteDebuggerLauncher.csproj index 2adfb83b..93a6e32e 100644 --- a/src/Extension/RemoteDebuggerLauncher/RemoteDebuggerLauncher.csproj +++ b/src/Extension/RemoteDebuggerLauncher/RemoteDebuggerLauncher.csproj @@ -76,8 +76,11 @@ + + + @@ -97,13 +100,14 @@ - + + @@ -326,6 +330,7 @@ + diff --git a/src/Extension/RemoteDebuggerLauncher/RemoteDebuggerLauncherPackage.cs b/src/Extension/RemoteDebuggerLauncher/RemoteDebuggerLauncherPackage.cs index b9e740b2..95e4309e 100644 --- a/src/Extension/RemoteDebuggerLauncher/RemoteDebuggerLauncherPackage.cs +++ b/src/Extension/RemoteDebuggerLauncher/RemoteDebuggerLauncherPackage.cs @@ -1,4 +1,4 @@ -// ---------------------------------------------------------------------------- +// ---------------------------------------------------------------------------- // // Copyright (c) Michael Koster. All rights reserved. // Licensed under the MIT License. @@ -57,6 +57,10 @@ protected override async Task InitializeAsync(CancellationToken cancellationToke // Add services implemented in this package AddService(typeof(SOptionsPageAccessor), CreateServiceAsync, true); + // Register the solution event listener + var solution = (await this.GeVsFacadeFactoryAsync()).GetVsSolution(); + + // When initialized asynchronously, the current thread may be a background thread at this point. // Do any initialization that requires the UI thread after switching to the UI thread. await JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken); @@ -70,6 +74,22 @@ protected override async Task InitializeAsync(CancellationToken cancellationToke } #endregion + #region IDisposable + /// + /// Dispose method to clean up resources. + /// + /// True if called from Dispose method. + protected override void Dispose(bool disposing) + { + if (disposing) + { + // Clear cached passphrases for security + //RemoteOperations.SecureShellPassphraseService.Instance.ClearCache(); + } + base.Dispose(disposing); + } + #endregion + #region Private Methods /// Factory methods responsible to create service instances registered in this package. /// diff --git a/src/Extension/RemoteDebuggerLauncher/RemoteOperations/ISecureShellKeyPassphraseService.cs b/src/Extension/RemoteDebuggerLauncher/RemoteOperations/ISecureShellKeyPassphraseService.cs new file mode 100644 index 00000000..e58c8d21 --- /dev/null +++ b/src/Extension/RemoteDebuggerLauncher/RemoteOperations/ISecureShellKeyPassphraseService.cs @@ -0,0 +1,45 @@ +// ---------------------------------------------------------------------------- +// +// Copyright (c) Michael Koster. All rights reserved. +// Licensed under the MIT License. +// +// ---------------------------------------------------------------------------- + +using System.Threading.Tasks; + +namespace RemoteDebuggerLauncher.RemoteOperations +{ + /// + /// Interface defining a service for managing SSH private key passphrases. + /// + internal interface ISecureShellKeyPassphraseService + { + /// + /// Gets the cached passphrase for the specified private key file. + /// + /// The path to the private key file. + /// The cached passphrase if available + /// true if passphrase is available, else false + bool TryGet(string privateKeyFilePath, out string passphrase); + + /// + /// Prompts the user for a passphrase and caches it for the current session. + /// + /// The path to the private key file. + /// The cached passphrase if available + /// true if passphrase is available, else false + /// Must be executed on the Main Thread. + bool Prompt(string privateKeyFilePath, out string passphrase); + + /// + /// Clears all cached passphrases. + /// + void Clear(); + + /// + /// Clears the cached passphrase for the specified private key file. + /// + /// The path to the private key file. + void Clear(string privateKeyFilePath); + } +} \ No newline at end of file diff --git a/src/Extension/RemoteDebuggerLauncher/RemoteOperations/ISecureShellSessionBaseService.cs b/src/Extension/RemoteDebuggerLauncher/RemoteOperations/ISecureShellSessionBaseService.cs index f4cce2c3..6f08ab74 100644 --- a/src/Extension/RemoteDebuggerLauncher/RemoteOperations/ISecureShellSessionBaseService.cs +++ b/src/Extension/RemoteDebuggerLauncher/RemoteOperations/ISecureShellSessionBaseService.cs @@ -32,6 +32,6 @@ internal interface ISecureShellSessionBaseService /// Create a new SSH commanding session. /// /// The session instance. - ISecureShellSessionCommandingService CreateCommandSession(); + Task CreateCommandSessionAsync(); } } diff --git a/src/Extension/RemoteDebuggerLauncher/RemoteOperations/SecureShellKeyPassphraseService.cs b/src/Extension/RemoteDebuggerLauncher/RemoteOperations/SecureShellKeyPassphraseService.cs new file mode 100644 index 00000000..90b55a41 --- /dev/null +++ b/src/Extension/RemoteDebuggerLauncher/RemoteOperations/SecureShellKeyPassphraseService.cs @@ -0,0 +1,118 @@ +// ---------------------------------------------------------------------------- +// +// Copyright (c) Michael Koster. All rights reserved. +// Licensed under the MIT License. +// +// ---------------------------------------------------------------------------- + +using System; +using System.Collections.Concurrent; +using System.Composition; +using System.IO; +using System.Security.Cryptography; +using System.Threading.Tasks; +using Microsoft.VisualStudio.Shell; + +namespace RemoteDebuggerLauncher.RemoteOperations +{ + /// + /// Service for managing SSH private key passphrases securely. + /// + [Export(typeof(ISecureShellKeyPassphraseService)), Shared] + + internal class SecureShellKeyPassphraseService : ISecureShellKeyPassphraseService + { + private readonly ConcurrentDictionary passphraseCache = new ConcurrentDictionary(); + + /// + public bool TryGet(string privateKeyFilePath, out string passphrase) + { + passphrase = null; + + if (string.IsNullOrEmpty(privateKeyFilePath)) + { + return false; + } + + var normalizedPath = Path.GetFullPath(privateKeyFilePath); + if (passphraseCache.TryGetValue(normalizedPath, out var encryptedPassphrase)) + { + passphrase = DecryptPassphrase(encryptedPassphrase); + return passphrase != null; + } + + return false; + } + + /// + public bool Prompt(string privateKeyFilePath, out string passphrase) + { + passphrase = null; + + ThreadHelper.ThrowIfNotOnUIThread(); + + if (string.IsNullOrEmpty(privateKeyFilePath)) + { + return false; + } + + (var viewModel,var result) = DialogFactory.CreateAndShowDialog( + new SecureShellPassphraseViewModel(ThreadHelper.JoinableTaskFactory, privateKeyFilePath)); + + if (result.HasValue && result.Value && !string.IsNullOrEmpty(viewModel.Passphrase)) + { + var normalizedPath = Path.GetFullPath(privateKeyFilePath); + var encryptedPassphrase = EncryptPassphrase(viewModel.Passphrase); + _ = passphraseCache.AddOrUpdate(normalizedPath, encryptedPassphrase, (key, oldValue) => encryptedPassphrase); + + passphrase = viewModel.Passphrase; + return true; + } + else + { + // User canceled or did not enter a passphrase + return false; + } + } + + /// + public void Clear() + { + passphraseCache.Clear(); + } + + /// + public void Clear(string privateKeyFilePath) + { + if (!string.IsNullOrEmpty(privateKeyFilePath)) + { + var normalizedPath = Path.GetFullPath(privateKeyFilePath); + _= passphraseCache.TryRemove(normalizedPath, out _); + } + } + + private static byte[] EncryptPassphrase(string passphrase) + { + if (string.IsNullOrEmpty(passphrase)) + { + // Handle empty passphrase + return Array.Empty(); + } + + var plainBytes = System.Text.Encoding.UTF8.GetBytes(passphrase); + return ProtectedData.Protect(plainBytes, null, DataProtectionScope.CurrentUser); + } + + private static string DecryptPassphrase(byte[] encryptedPassphrase) + { + if (encryptedPassphrase == null || encryptedPassphrase.Length == 0) + { + // Handle empty passphrase + return null; + } + + var plainBytes = ProtectedData.Unprotect(encryptedPassphrase, null, DataProtectionScope.CurrentUser); + return System.Text.Encoding.UTF8.GetString(plainBytes); + } + } +} \ No newline at end of file diff --git a/src/Extension/RemoteDebuggerLauncher/RemoteOperations/SecureShellKeySetupSettings.cs b/src/Extension/RemoteDebuggerLauncher/RemoteOperations/SecureShellKeySetupSettings.cs index 774eb6d1..039eef06 100644 --- a/src/Extension/RemoteDebuggerLauncher/RemoteOperations/SecureShellKeySetupSettings.cs +++ b/src/Extension/RemoteDebuggerLauncher/RemoteOperations/SecureShellKeySetupSettings.cs @@ -12,7 +12,7 @@ namespace RemoteDebuggerLauncher.RemoteOperations { /// - /// Data container holding configuration data for the + /// Data container holding configuration data for the /// internal class SecureShellKeySetupSettings { diff --git a/src/Extension/RemoteDebuggerLauncher/RemoteOperations/SecureShellRemoteBulkCopyDeltaSessionService.cs b/src/Extension/RemoteDebuggerLauncher/RemoteOperations/SecureShellRemoteBulkCopyDeltaSessionService.cs index bf64e3d2..c5d2a73b 100644 --- a/src/Extension/RemoteDebuggerLauncher/RemoteOperations/SecureShellRemoteBulkCopyDeltaSessionService.cs +++ b/src/Extension/RemoteDebuggerLauncher/RemoteOperations/SecureShellRemoteBulkCopyDeltaSessionService.cs @@ -37,7 +37,7 @@ internal SecureShellRemoteBulkCopyDeltaSessionService(ISecureShellSessionBaseSer public Task ExecuteSingleCommandAsync(string commandText) => session.ExecuteSingleCommandAsync(commandText); /// - public ISecureShellSessionCommandingService CreateCommandSession() => session.CreateCommandSession(); + public Task CreateCommandSessionAsync() => session.CreateCommandSessionAsync(); /// public async Task UploadFolderRecursiveAsync(string localSourcePath, string remoteTargetPath, IOutputPaneWriterService progressOutputPaneWriter = null) @@ -48,7 +48,7 @@ public async Task UploadFolderRecursiveAsync(string localSourcePath, string remo progressOutputPaneWriter?.Write(Resources.RemoteCommandCommonSshTarget, Settings.UserName, Settings.HostName); progressOutputPaneWriter?.WriteLine(Resources.RemoteCommandDeployRemoteFolderScpDeltaStart); - using (var commands = session.CreateCommandSession()) + using (var commands = await session.CreateCommandSessionAsync()) { // Step 1: Get the user home, needed to normalize path expressions var userHome = await GetUserHomeAsync(commands); diff --git a/src/Extension/RemoteDebuggerLauncher/RemoteOperations/SecureShellRemoteBulkCopyRsyncSessionService.cs b/src/Extension/RemoteDebuggerLauncher/RemoteOperations/SecureShellRemoteBulkCopyRsyncSessionService.cs index cc912017..ad9f561b 100644 --- a/src/Extension/RemoteDebuggerLauncher/RemoteOperations/SecureShellRemoteBulkCopyRsyncSessionService.cs +++ b/src/Extension/RemoteDebuggerLauncher/RemoteOperations/SecureShellRemoteBulkCopyRsyncSessionService.cs @@ -34,7 +34,7 @@ internal SecureShellRemoteBulkCopyRsyncSessionService(ISecureShellSessionBaseSer public Task ExecuteSingleCommandAsync(string commandText) => session.ExecuteSingleCommandAsync(commandText); /// - public ISecureShellSessionCommandingService CreateCommandSession() => session.CreateCommandSession(); + public Task CreateCommandSessionAsync() => session.CreateCommandSessionAsync(); /// public async Task UploadFolderRecursiveAsync(string localSourcePath, string remoteTargetPath, IOutputPaneWriterService progressOutputPaneWriter = null) @@ -42,7 +42,7 @@ public async Task UploadFolderRecursiveAsync(string localSourcePath, string remo ThrowIf.ArgumentNullOrEmpty(localSourcePath, nameof(localSourcePath)); ThrowIf.ArgumentNullOrEmpty(remoteTargetPath, nameof(remoteTargetPath)); - using (var commands = session.CreateCommandSession()) + using (var commands = await session.CreateCommandSessionAsync()) { progressOutputPaneWriter?.Write(Resources.RemoteCommandCommonSshTarget, session.Settings.UserName, session.Settings.HostName); progressOutputPaneWriter?.WriteLine(Resources.RemoteCommandDeployRemoteFolderRsyncStart); @@ -60,7 +60,7 @@ public async Task UploadFileAsync(string localFilePath, string remoteFilePath, I ThrowIf.ArgumentNullOrEmpty(localFilePath, nameof(localFilePath)); ThrowIf.ArgumentNullOrEmpty(remoteFilePath, nameof(remoteFilePath)); - using (var commands = session.CreateCommandSession()) + using (var commands = await session.CreateCommandSessionAsync()) { progressOutputPaneWriter?.Write(Resources.RemoteCommandCommonSshTarget, session.Settings.UserName, session.Settings.HostName); progressOutputPaneWriter?.WriteLine(Resources.RemoteCommandDeployFileProgress, localFilePath, remoteFilePath); diff --git a/src/Extension/RemoteDebuggerLauncher/RemoteOperations/SecureShellRemoteOperationsService.cs b/src/Extension/RemoteDebuggerLauncher/RemoteOperations/SecureShellRemoteOperationsService.cs index cdaa7c36..b57fdf6b 100644 --- a/src/Extension/RemoteDebuggerLauncher/RemoteOperations/SecureShellRemoteOperationsService.cs +++ b/src/Extension/RemoteDebuggerLauncher/RemoteOperations/SecureShellRemoteOperationsService.cs @@ -103,7 +103,7 @@ public async Task TryInstallVsDbgOnlineAsync(string version = Constants.De { try { - using (var commands = session.CreateCommandSession()) + using (var commands = await session.CreateCommandSessionAsync()) { outputPaneWriter.Write(LogHost, Resources.RemoteCommandCommonSshTarget, session.Settings.UserName, session.Settings.HostName); outputPaneWriter.WriteLine(Resources.RemoteCommandInstallDebuggerOnlineCommonProgress); @@ -170,7 +170,7 @@ public async Task TryInstallVsDbgOfflineAsync(string version = Constants.Debugge outputPaneWriter.Write(Resources.RemoteCommandInstallDebuggerOfflineOutputPaneProgressInstalling); - using (var commandSession = session.CreateCommandSession()) + using (var commandSession = await session.CreateCommandSessionAsync()) { // remove all files in the target folder, in case the debugger was installed before await CleanRemoteFolderAsync(commandSession, debuggerInstallPath); @@ -371,7 +371,7 @@ public async Task SetupAspNetDeveloperCertificateAsync(SetupMode mode, byte[] ce string dotnetExecutable = UnixPath.Combine(configurationAggregator.QueryDotNetInstallFolderPath(), "dotnet"); //Check if ASP.NET runtime is installed - using (var commands = session.CreateCommandSession()) + using (var commands = await session.CreateCommandSessionAsync()) { // Step 1: Check certificate status outputPaneWriter.Write(LogHost, Resources.RemoteCommandCommonSshTarget, session.Settings.UserName, session.Settings.HostName); @@ -472,7 +472,7 @@ private static async Task CleanRemoteFolderAsync(ISecureShellSessionCommandingSe private async Task CleanFolderAsync(string remoteTargetPath, bool clean) { ThrowIf.ArgumentNullOrEmpty(remoteTargetPath, nameof(remoteTargetPath)); - using (var commandSession = session.CreateCommandSession()) + using (var commandSession = await session.CreateCommandSessionAsync()) { if (clean) { @@ -510,7 +510,7 @@ private async Task TryInstallDotNetSDKOnlineAsync(string channel) { try { - using (var commands = session.CreateCommandSession()) + using (var commands = await session.CreateCommandSessionAsync()) { outputPaneWriter.Write(LogHost, Resources.RemoteCommandCommonSshTarget, session.Settings.UserName, session.Settings.HostName); outputPaneWriter.WriteLine(Resources.RemoteCommandInstallDotnetSdkOnlineOutputPaneProgress); @@ -547,7 +547,7 @@ private async Task TryInstallDotNetRuntimeOnlineAsync(string channel, stri { try { - using (var commands = session.CreateCommandSession()) + using (var commands = await session.CreateCommandSessionAsync()) { outputPaneWriter.Write(LogHost, Resources.RemoteCommandCommonSshTarget, session.Settings.UserName, session.Settings.HostName); outputPaneWriter.WriteLine(Resources.RemoteCommandInstallDotnetRuntimeOnlineOutputPaneProgress); @@ -729,7 +729,7 @@ private async Task DownloadDotnetAsync(string channel, string runtime = private async Task InstallDotnetAsync(string filePath) { - using (var commands = session.CreateCommandSession()) + using (var commands = await session.CreateCommandSessionAsync()) { var fileName = Path.GetFileName(filePath); diff --git a/src/Extension/RemoteDebuggerLauncher/RemoteOperations/SecureShellSessionService.cs b/src/Extension/RemoteDebuggerLauncher/RemoteOperations/SecureShellSessionService.cs index 949d34b6..68c5918f 100644 --- a/src/Extension/RemoteDebuggerLauncher/RemoteOperations/SecureShellSessionService.cs +++ b/src/Extension/RemoteDebuggerLauncher/RemoteOperations/SecureShellSessionService.cs @@ -8,6 +8,8 @@ using System; using System.IO; using System.Threading.Tasks; +using Microsoft.VisualStudio.Shell; +using Microsoft.VisualStudio.Threading; using Renci.SshNet; using Renci.SshNet.Common; @@ -23,10 +25,12 @@ namespace RemoteDebuggerLauncher.RemoteOperations internal class SecureShellSessionService : ISecureShellSessionService, IRemoteBulkCopySessionService { private readonly SecureShellSessionSettings settings; + private readonly ISecureShellKeyPassphraseService passphraseService; - internal SecureShellSessionService(SecureShellSessionSettings settings) + internal SecureShellSessionService(SecureShellSessionSettings settings, ISecureShellKeyPassphraseService passphraseService) { this.settings = settings ?? throw new ArgumentNullException(nameof(settings)); + this.passphraseService = passphraseService ?? throw new ArgumentNullException(nameof(passphraseService)); } /// @@ -35,11 +39,11 @@ internal SecureShellSessionService(SecureShellSessionSettings settings) /// public Task ExecuteSingleCommandAsync(string commandText) { - return Task.Run(() => + return Task.Run(async () => { try { - using (var client = CreateSshClient()) + using (var client = await CreateSshClientAsync()) { client.ConnectionInfo.Timeout = TimeSpan.FromSeconds(5); client.Connect(); @@ -69,12 +73,12 @@ public Task UploadFolderRecursiveAsync(string localSourcePath, string remoteTarg progressOutputPaneWriter?.Write(Resources.RemoteCommandCommonSshTarget, Settings.UserName, Settings.HostName); progressOutputPaneWriter?.WriteLine(Resources.RemoteCommandDeployRemoteFolderScpFullStart); - return Task.Run(() => + return Task.Run(async () => { try { var sourcePathInfo = new DirectoryInfo(localSourcePath); - using (var client = CreateScpClient()) + using (var client = await CreateScpClientAsync()) { // for the moment, we assume that the path names does not have any character that have special meaning for a Linux host client.RemotePathTransformation = RemotePathTransformation.None; @@ -107,13 +111,13 @@ public Task UploadFileAsync(string localSourcePath, string remoteTargetPath, IOu ThrowIf.ArgumentNullOrEmpty(localSourcePath, nameof(localSourcePath)); ThrowIf.ArgumentNullOrEmpty(remoteTargetPath, nameof(remoteTargetPath)); - return Task.Run(() => + return Task.Run(async () => { try { var sourcePathInfo = new FileInfo(localSourcePath); - using (var client = CreateScpClient()) + using (var client = await CreateScpClientAsync()) { // for the moment, we assume that the path names does not have any character that have special meaning for a Linux host client.RemotePathTransformation = RemotePathTransformation.None; @@ -146,11 +150,11 @@ public Task UploadFileAsync(Stream localStream, string remoteTargetPath) ThrowIf.ArgumentNull(localStream, nameof(localStream)); ThrowIf.ArgumentNullOrEmpty(remoteTargetPath, nameof(remoteTargetPath)); - return Task.Run(() => + return Task.Run(async () => { try { - using (var client = CreateScpClient()) + using (var client = await CreateScpClientAsync()) { // for the moment, we assume that the path names does not have any character that have special meaning for a Linux host client.RemotePathTransformation = RemotePathTransformation.None; @@ -176,7 +180,7 @@ public async Task CleanFolderAsync(string remoteTargetPath, bool clean) ThrowIf.ArgumentNullOrEmpty(remoteTargetPath, nameof(remoteTargetPath)); if (clean) { - using (var commandSession = CreateCommandSession()) + using (var commandSession = await CreateCommandSessionAsync()) { _ = await commandSession.ExecuteCommandAsync($"[ -d \"{remoteTargetPath}\" ] && rm -rf \"{remoteTargetPath}\"/*"); _ = await commandSession.ExecuteCommandAsync($"mkdir -p \"{remoteTargetPath}\""); @@ -185,14 +189,14 @@ public async Task CleanFolderAsync(string remoteTargetPath, bool clean) } /// - public ISecureShellSessionCommandingService CreateCommandSession() + public async Task CreateCommandSessionAsync() { - var client = CreateSshClient(); + var client = await CreateSshClientAsync(); client.ConnectionInfo.Timeout = TimeSpan.FromSeconds(5); return new SecureShellSessionCommandingService(client); } - private SshClient CreateSshClient() + private async Task CreateSshClientAsync() { if (string.IsNullOrWhiteSpace(settings.UserName)) { @@ -204,15 +208,59 @@ private SshClient CreateSshClient() throw new SecureShellSessionException(ExceptionMessages.SecureShellSessionNoPrivateKey); } - var key = new PrivateKeyFile(settings.PrivateKeyFile); + var key = await CreatePrivateKeyFileAsync(settings.PrivateKeyFile); return new SshClient(settings.HostNameIPv4, settings.HostPort, settings.UserName, key); } - private ScpClient CreateScpClient() + private async Task CreateScpClientAsync() { - var key = new PrivateKeyFile(settings.PrivateKeyFile); + var key = await CreatePrivateKeyFileAsync(settings.PrivateKeyFile); return new ScpClient(settings.HostNameIPv4, settings.HostPort, settings.UserName, key); } + + private async Task CreatePrivateKeyFileAsync(string privateKeyFilePath) + { + // First, check if we have a cached passphrase + if (passphraseService.TryGet(privateKeyFilePath, out var passphrase)) + { + try + { + return new PrivateKeyFile(privateKeyFilePath, passphrase); + } + catch (SshException) + { + // Cached passphrase is wrong, clear it and continue + passphraseService.Clear(privateKeyFilePath); + } + } + + // Try without passphrase first + try + { + return new PrivateKeyFile(privateKeyFilePath); + } + catch (SshException) + { + // Assume the key is encrypted, proceed to passphrase handling + + try + { + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + if (passphraseService.Prompt(privateKeyFilePath, out passphrase)) + { + return new PrivateKeyFile(privateKeyFilePath, passphrase); + } + else + { + throw new SecureShellSessionException(ExceptionMessages.SecureShellSessionPassphraseRequired); + } + } + finally + { + await TaskScheduler.Default; + } + } + } } } diff --git a/src/Extension/RemoteDebuggerLauncher/VsInterop/IVsSolutionEventsFacade.cs b/src/Extension/RemoteDebuggerLauncher/VsInterop/IVsSolutionEventsFacade.cs new file mode 100644 index 00000000..c538c5b6 --- /dev/null +++ b/src/Extension/RemoteDebuggerLauncher/VsInterop/IVsSolutionEventsFacade.cs @@ -0,0 +1,22 @@ +// ---------------------------------------------------------------------------- +// +// Copyright (c) Michael Koster. All rights reserved. +// Licensed under the MIT License. +// +// ---------------------------------------------------------------------------- + +using System; + +namespace RemoteDebuggerLauncher +{ + /// + /// Interface providing events for solution changes. + /// + internal interface IVsSolutionEventsFacade + { + /// + /// Occurs when when Visual Studio after VS closed a solution. + /// + event EventHandler AfterCloseSolution; + } +} diff --git a/src/Extension/RemoteDebuggerLauncher/VsInterop/IVsSolutionFacade.cs b/src/Extension/RemoteDebuggerLauncher/VsInterop/IVsSolutionFacade.cs index 4cc15df8..e06344d9 100644 --- a/src/Extension/RemoteDebuggerLauncher/VsInterop/IVsSolutionFacade.cs +++ b/src/Extension/RemoteDebuggerLauncher/VsInterop/IVsSolutionFacade.cs @@ -15,6 +15,12 @@ namespace RemoteDebuggerLauncher /// internal interface IVsSolutionFacade { + /// + /// Gets the Solution events facade. + /// + /// The instance. + IVsSolutionEventsFacade EventsFacade { get; } + /// /// Gets the active (startup) configured projects. /// diff --git a/src/Extension/RemoteDebuggerLauncher/VsInterop/VsFacadeFactory.cs b/src/Extension/RemoteDebuggerLauncher/VsInterop/VsFacadeFactory.cs index 8f877461..04a39434 100644 --- a/src/Extension/RemoteDebuggerLauncher/VsInterop/VsFacadeFactory.cs +++ b/src/Extension/RemoteDebuggerLauncher/VsInterop/VsFacadeFactory.cs @@ -7,6 +7,7 @@ using System; using System.Composition; + using Microsoft.VisualStudio.ProjectSystem; using Microsoft.VisualStudio.Shell; using Microsoft.VisualStudio.Shell.Interop; @@ -17,7 +18,7 @@ namespace RemoteDebuggerLauncher /// /// Visual Studio Interop facade factory implementation. /// - [Export(typeof(IVsFacadeFactory))] + [Export(typeof(IVsFacadeFactory)), Shared] internal class VsFacadeFactory : IVsFacadeFactory { private readonly IServiceProvider serviceProvider; diff --git a/src/Extension/RemoteDebuggerLauncher/VsInterop/VsSolutionEventsListener.cs b/src/Extension/RemoteDebuggerLauncher/VsInterop/VsSolutionEventsListener.cs new file mode 100644 index 00000000..580d02ac --- /dev/null +++ b/src/Extension/RemoteDebuggerLauncher/VsInterop/VsSolutionEventsListener.cs @@ -0,0 +1,65 @@ +// ---------------------------------------------------------------------------- +// +// Copyright (c) Michael Koster. All rights reserved. +// Licensed under the MIT License. +// +// ---------------------------------------------------------------------------- + +using System; +using Microsoft.VisualStudio; +using Microsoft.VisualStudio.Shell; +using Microsoft.VisualStudio.Shell.Interop; + +namespace RemoteDebuggerLauncher +{ + /// + /// Responsible for listening to Visual Studio solution events and expose them as .NET events + /// Implements the + /// Implements the + /// + /// + /// + internal class VsSolutionEventsListener : IVsSolutionEvents, IVsSolutionEventsFacade, IDisposable + { + private readonly IVsSolution vsSolution; + private uint cookie; + + public VsSolutionEventsListener(IVsSolution vsSolution) + { + this.vsSolution = vsSolution; + + // Advise for solution events + ThreadHelper.ThrowIfNotOnUIThread(); + _ = vsSolution.AdviseSolutionEvents(this, out cookie); + } + + /// + public event EventHandler AfterCloseSolution; + + public int OnAfterOpenProject(IVsHierarchy pHierarchy, int fAdded) => VSConstants.S_OK; + public int OnQueryCloseProject(IVsHierarchy pHierarchy, int fRemoving, ref int pfCancel) => VSConstants.S_OK; + public int OnBeforeCloseProject(IVsHierarchy pHierarchy, int fRemoved) => VSConstants.S_OK; + public int OnAfterLoadProject(IVsHierarchy pStubHierarchy, IVsHierarchy pRealHierarchy) => VSConstants.S_OK; + public int OnQueryUnloadProject(IVsHierarchy pRealHierarchy, ref int pfCancel) => VSConstants.S_OK; + public int OnBeforeUnloadProject(IVsHierarchy pRealHierarchy, IVsHierarchy pStubHierarchy) => VSConstants.S_OK; + public int OnAfterOpenSolution(object pUnkReserved, int fNewSolution) => VSConstants.S_OK; + public int OnQueryCloseSolution(object pUnkReserved, ref int pfCancel) => VSConstants.S_OK; + public int OnBeforeCloseSolution(object pUnkReserved) => VSConstants.S_OK; + public int OnAfterCloseSolution(object pUnkReserved) + { + AfterCloseSolution?.Invoke(this, EventArgs.Empty); + return VSConstants.S_OK; + } + + /// + public void Dispose() + { + ThreadHelper.ThrowIfNotOnUIThread(); + if (cookie != 0) + { + _ = vsSolution.UnadviseSolutionEvents(cookie); + cookie = 0; + } + } + } +} \ No newline at end of file diff --git a/src/Extension/RemoteDebuggerLauncher/VsInterop/VsSolutionFacade.cs b/src/Extension/RemoteDebuggerLauncher/VsInterop/VsSolutionFacade.cs index b680eeff..e3fee468 100644 --- a/src/Extension/RemoteDebuggerLauncher/VsInterop/VsSolutionFacade.cs +++ b/src/Extension/RemoteDebuggerLauncher/VsInterop/VsSolutionFacade.cs @@ -11,6 +11,7 @@ using System.Threading.Tasks; using Microsoft.VisualStudio.ProjectSystem; using Microsoft.VisualStudio.Shell; +using Microsoft.VisualStudio.Shell.Interop; namespace RemoteDebuggerLauncher { @@ -22,6 +23,7 @@ namespace RemoteDebuggerLauncher internal class VsSolutionFacade : IVsSolutionFacade { private readonly IAsyncServiceProvider serviceProvider; + private readonly VsSolutionEventsListener solutionEventsListener; /// /// Initializes a new instance of the class. @@ -30,8 +32,13 @@ internal class VsSolutionFacade : IVsSolutionFacade public VsSolutionFacade(IServiceProvider serviceProvider) { this.serviceProvider = serviceProvider as IAsyncServiceProvider; + + // Register the solution event listener + solutionEventsListener = new VsSolutionEventsListener(serviceProvider.GetService()); } + public IVsSolutionEventsFacade EventsFacade { get { return solutionEventsListener; } } + /// public async Task> GetActiveConfiguredProjectsAsync() { diff --git a/src/Extension/RemoteDebuggerLauncher/source.extension.vsixmanifest b/src/Extension/RemoteDebuggerLauncher/source.extension.vsixmanifest index 31702e93..b06b63d6 100644 --- a/src/Extension/RemoteDebuggerLauncher/source.extension.vsixmanifest +++ b/src/Extension/RemoteDebuggerLauncher/source.extension.vsixmanifest @@ -21,7 +21,7 @@ - + diff --git a/src/Extension/RemoteDebuggerLauncherUI/ExceptionMessages.Designer.cs b/src/Extension/RemoteDebuggerLauncherUI/ExceptionMessages.Designer.cs index 1fe1af4a..dd67c840 100644 --- a/src/Extension/RemoteDebuggerLauncherUI/ExceptionMessages.Designer.cs +++ b/src/Extension/RemoteDebuggerLauncherUI/ExceptionMessages.Designer.cs @@ -104,5 +104,14 @@ public static string SecureShellSessionNoUserName { return ResourceManager.GetString("SecureShellSessionNoUserName", resourceCulture); } } + + /// + /// Looks up a localized string similar to SSH private key passphrase is required.. + /// + public static string SecureShellSessionPassphraseRequired { + get { + return ResourceManager.GetString("SecureShellSessionPassphraseRequired", resourceCulture); + } + } } } diff --git a/src/Extension/RemoteDebuggerLauncherUI/ExceptionMessages.resx b/src/Extension/RemoteDebuggerLauncherUI/ExceptionMessages.resx index db26c4d0..b703312b 100644 --- a/src/Extension/RemoteDebuggerLauncherUI/ExceptionMessages.resx +++ b/src/Extension/RemoteDebuggerLauncherUI/ExceptionMessages.resx @@ -132,4 +132,7 @@ no username provided. + + SSH private key passphrase is required. + \ No newline at end of file diff --git a/src/Extension/RemoteDebuggerLauncherUI/GlobalSuppressions.cs b/src/Extension/RemoteDebuggerLauncherUI/GlobalSuppressions.cs new file mode 100644 index 00000000..aa42688a --- /dev/null +++ b/src/Extension/RemoteDebuggerLauncherUI/GlobalSuppressions.cs @@ -0,0 +1,14 @@ +// ---------------------------------------------------------------------------- +// +// Copyright (c) Michael Koster. All rights reserved. +// Licensed under the MIT License. +// +// ---------------------------------------------------------------------------- + +// This file is used by Code Analysis to maintain SuppressMessage attributes that are applied to this project. +// Project-level suppressions either have no target or are given a specific target and scoped to a namespace, type, member, etc. + +using System.Diagnostics.CodeAnalysis; + +[assembly: SuppressMessage("Minor Code Smell", "S2325:Methods and properties that don't access instance data should be static", Justification = "By design", Scope = "member", Target = "~M:RemoteDebuggerLauncher.RemoteOperations.SecureShellPassphraseViewModel.HandleOkCommand(Microsoft.VisualStudio.PlatformUI.DialogWindow)")] +[assembly: SuppressMessage("Minor Code Smell", "S2325:Methods and properties that don't access instance data should be static", Justification = "By design", Scope = "member", Target = "~M:RemoteDebuggerLauncher.RemoteOperations.SecureShellPassphraseViewModel.HandleCancelCommand(Microsoft.VisualStudio.PlatformUI.DialogWindow)")] diff --git a/src/Extension/RemoteDebuggerLauncherUI/RemoteDebuggerLauncherUI.csproj b/src/Extension/RemoteDebuggerLauncherUI/RemoteDebuggerLauncherUI.csproj index 9a60c974..25c11163 100644 --- a/src/Extension/RemoteDebuggerLauncherUI/RemoteDebuggerLauncherUI.csproj +++ b/src/Extension/RemoteDebuggerLauncherUI/RemoteDebuggerLauncherUI.csproj @@ -78,6 +78,9 @@ True ExceptionMessages.resx + + + @@ -108,6 +111,13 @@ InstallDotnetDialogWindow.xaml + + SecureShellPassphraseDialogWindow.xaml + + + MSBuild:Compile + Designer + MSBuild:Compile Designer @@ -158,5 +168,6 @@ RemoteDebuggerLauncherShared + \ No newline at end of file diff --git a/src/Extension/RemoteDebuggerLauncherUI/RemoteOperations/ViewModels/SecureShellPassphraseViewModel.cs b/src/Extension/RemoteDebuggerLauncherUI/RemoteOperations/ViewModels/SecureShellPassphraseViewModel.cs new file mode 100644 index 00000000..f9189ffc --- /dev/null +++ b/src/Extension/RemoteDebuggerLauncherUI/RemoteOperations/ViewModels/SecureShellPassphraseViewModel.cs @@ -0,0 +1,56 @@ +// ---------------------------------------------------------------------------- +// +// Copyright (c) Michael Koster. All rights reserved. +// Licensed under the MIT License. +// +// ---------------------------------------------------------------------------- + +using Microsoft.VisualStudio.PlatformUI; +using Microsoft.VisualStudio.Threading; + +namespace RemoteDebuggerLauncher.RemoteOperations +{ + public class SecureShellPassphraseViewModel : ViewModelBase + { + public SecureShellPassphraseViewModel(JoinableTaskFactory joinableTaskFactory, string keyFilePath) + { + // assign properties + KeyFilePath = keyFilePath; + + // wire-up commands + OkCommand = new DelegateCommand(HandleOkCommand, canExecute => Validate(), joinableTaskFactory); + CancelCommand = new DelegateCommand(HandleCancelCommand, null, joinableTaskFactory); + } + + public string KeyFilePath { get; } + + public string Passphrase { get; set; } + + public DelegateCommand OkCommand { get; } + + public DelegateCommand CancelCommand { get; } + + private void HandleOkCommand(DialogWindow dialog) + { + if (dialog != null) + { + dialog.DialogResult = true; + dialog.Close(); + } + } + + private void HandleCancelCommand(DialogWindow dialog) + { + if (dialog != null) + { + dialog.DialogResult = false; + dialog.Close(); + } + } + + private bool Validate() + { + return Passphrase.Length > 0; + } + } +} diff --git a/src/Extension/RemoteDebuggerLauncherUI/RemoteOperations/Views/SecureShellPassphraseDialogWindow.xaml b/src/Extension/RemoteDebuggerLauncherUI/RemoteOperations/Views/SecureShellPassphraseDialogWindow.xaml new file mode 100644 index 00000000..dcfa3cf3 --- /dev/null +++ b/src/Extension/RemoteDebuggerLauncherUI/RemoteOperations/Views/SecureShellPassphraseDialogWindow.xaml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + +