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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Extension/RemoteDebuggerLauncherUI/RemoteOperations/Views/SecureShellPassphraseDialogWindow.xaml.cs b/src/Extension/RemoteDebuggerLauncherUI/RemoteOperations/Views/SecureShellPassphraseDialogWindow.xaml.cs
new file mode 100644
index 00000000..12f54f6b
--- /dev/null
+++ b/src/Extension/RemoteDebuggerLauncherUI/RemoteOperations/Views/SecureShellPassphraseDialogWindow.xaml.cs
@@ -0,0 +1,28 @@
+// ----------------------------------------------------------------------------
+//
+// Copyright (c) Michael Koster. All rights reserved.
+// Licensed under the MIT License.
+//
+// ----------------------------------------------------------------------------
+
+using Microsoft.VisualStudio.PlatformUI;
+
+namespace RemoteDebuggerLauncher.RemoteOperations
+{
+ ///
+ /// Modal Dialog providing the UI options for the Install .NET Command.
+ /// Implements the
+ ///
+ ///
+ public partial class SecureShellPassphraseDialogWindow : DialogWindow
+ {
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public SecureShellPassphraseDialogWindow()
+ {
+ InitializeComponent();
+ }
+ }
+}
+
diff --git a/src/Extension/RemoteDebuggerLauncherUI/Resources.Designer.cs b/src/Extension/RemoteDebuggerLauncherUI/Resources.Designer.cs
index acca8891..745ce888 100644
--- a/src/Extension/RemoteDebuggerLauncherUI/Resources.Designer.cs
+++ b/src/Extension/RemoteDebuggerLauncherUI/Resources.Designer.cs
@@ -1424,6 +1424,42 @@ public static string SecureShellDeployProviderDeployFailed {
}
}
+ ///
+ /// Looks up a localized string similar to Enter passphrase for private key, then click OK to apply.
+ ///
+ public static string SecureShellKeyPassphraseDialogHeaderText {
+ get {
+ return ResourceManager.GetString("SecureShellKeyPassphraseDialogHeaderText", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Key File.
+ ///
+ public static string SecureShellKeyPassphraseDialogKeyFileLabel {
+ get {
+ return ResourceManager.GetString("SecureShellKeyPassphraseDialogKeyFileLabel", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Passphrase.
+ ///
+ public static string SecureShellKeyPassphraseDialogPassphraseLabel {
+ get {
+ return ResourceManager.GetString("SecureShellKeyPassphraseDialogPassphraseLabel", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to SSH Private Key Passphrase.
+ ///
+ public static string SecureShellKeyPassphraseDialogTitle {
+ get {
+ return ResourceManager.GetString("SecureShellKeyPassphraseDialogTitle", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Please select the browser you want to use to start debugging..
///
diff --git a/src/Extension/RemoteDebuggerLauncherUI/Resources.resx b/src/Extension/RemoteDebuggerLauncherUI/Resources.resx
index 5111ee6d..398b1936 100644
--- a/src/Extension/RemoteDebuggerLauncherUI/Resources.resx
+++ b/src/Extension/RemoteDebuggerLauncherUI/Resources.resx
@@ -681,4 +681,17 @@ Windows OpenSSH ssh-keygen reported:
running 'ssh-keyscan.exe {0}'
+
+ SSH Private Key Passphrase
+
+
+ Enter passphrase for private key, then click OK to apply
+ Header asking for the passkey
+
+
+ Key File
+
+
+ Passphrase
+
\ No newline at end of file
diff --git a/src/Extension/RemoteDebuggerLauncherUI/Utilities/DialogFactory.cs b/src/Extension/RemoteDebuggerLauncherUI/Utilities/DialogFactory.cs
new file mode 100644
index 00000000..471a0c93
--- /dev/null
+++ b/src/Extension/RemoteDebuggerLauncherUI/Utilities/DialogFactory.cs
@@ -0,0 +1,42 @@
+// ----------------------------------------------------------------------------
+//
+// Copyright (c) Michael Koster. All rights reserved.
+// Licensed under the MIT License.
+//
+// ----------------------------------------------------------------------------
+
+using Microsoft.VisualStudio.PlatformUI;
+
+namespace RemoteDebuggerLauncher
+{
+ ///
+ /// Utility class providing factory methods to create dialog windows.
+ ///
+ public static class DialogFactory
+ {
+ ///
+ /// Creates and shows dialog.
+ ///
+ /// The type of the view.
+ /// The type of the view model.
+ /// The view model.
+ /// System.ValueTuple<TViewModel, System.Nullable<System.Boolean>>.
+ public static (TViewModel viewModel, bool? result) CreateAndShowDialog(TViewModel viewModel) where TView : DialogWindow, new()
+ {
+ var view = Create(viewModel);
+
+ var result = view.ShowDialog();
+ return (viewModel, result);
+ }
+
+ internal static TView Create(TViewModel viewModel) where TView : DialogWindow, new()
+ {
+ var view = new TView()
+ {
+ DataContext = viewModel
+ };
+
+ return view;
+ }
+ }
+}
diff --git a/src/Tests/RemoteDebuggerLauncherUnitTests/RemoteDebuggerLauncherUnitTests.csproj b/src/Tests/RemoteDebuggerLauncherUnitTests/RemoteDebuggerLauncherUnitTests.csproj
index 9bb6dadc..a1288f5a 100644
--- a/src/Tests/RemoteDebuggerLauncherUnitTests/RemoteDebuggerLauncherUnitTests.csproj
+++ b/src/Tests/RemoteDebuggerLauncherUnitTests/RemoteDebuggerLauncherUnitTests.csproj
@@ -53,6 +53,7 @@
+
diff --git a/src/Tests/RemoteDebuggerLauncherUnitTests/SecureShellPassphraseServiceTests.cs b/src/Tests/RemoteDebuggerLauncherUnitTests/SecureShellPassphraseServiceTests.cs
new file mode 100644
index 00000000..d93bcccc
--- /dev/null
+++ b/src/Tests/RemoteDebuggerLauncherUnitTests/SecureShellPassphraseServiceTests.cs
@@ -0,0 +1,94 @@
+// ----------------------------------------------------------------------------
+//
+// Copyright (c) Michael Koster. All rights reserved.
+// Licensed under the MIT License.
+//
+// ----------------------------------------------------------------------------
+
+using System.Diagnostics.CodeAnalysis;
+using System.IO;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using RemoteDebuggerLauncher.RemoteOperations;
+
+namespace RemoteDebuggerLauncherUnitTests
+{
+ [TestClass]
+ public class SecureShellPassphraseServiceTests
+ {
+ private ISecureShellKeyPassphraseService service;
+ private string testKeyPath;
+
+ [TestInitialize]
+ public void TestInitialize()
+ {
+ service = new SecureShellKeyPassphraseService();
+ testKeyPath = Path.Combine(Path.GetTempPath(), "test_key");
+ File.WriteAllText(testKeyPath, "dummy key content");
+ }
+
+ [TestCleanup]
+ public void TestCleanup()
+ {
+ service.Clear();
+ if (File.Exists(testKeyPath))
+ {
+ File.Delete(testKeyPath);
+ }
+ }
+
+ [TestMethod]
+ public void GetCachedPassphrase_NoCache_ReturnsNull()
+ {
+ // Act
+ var result = service.TryGet(testKeyPath, out var passphrase);
+
+ // Assert
+ Assert.IsTrue(result);
+ Assert.IsNull(passphrase);
+ }
+
+ [TestMethod]
+ [SuppressMessage("Blocker Code Smell", "S2699:Tests should include assertions", Justification = "not thowing is the assert")]
+ public void ClearCachedPassphrase_ExistingKey_RemovesFromCache()
+ {
+ // Act
+ service.Clear(testKeyPath);
+ service.Clear(null);
+ service.Clear("");
+
+ // Assert (should not throw)
+ }
+
+ [TestMethod]
+ [SuppressMessage("Blocker Code Smell", "S2699:Tests should include assertions", Justification = "not thowing is the assert")]
+ public void ClearCache_ClearsAllCachedPassphrases()
+ {
+ // Act
+ service.Clear();
+
+ // Assert (should not throw)
+ }
+
+ [TestMethod]
+ public void GetCachedPassphrase_NullPath_ReturnsFalse()
+ {
+ // Act
+ var result = service.TryGet(null, out var passphrase);
+
+ // Assert
+ Assert.IsFalse(result);
+ Assert.IsNull(passphrase);
+ }
+
+ [TestMethod]
+ public void GetCachedPassphrase_EmptyPath_ReturnsFalse()
+ {
+ // Act
+ var result = service.TryGet("", out var passphrase);
+
+ // Assert
+ Assert.IsFalse(result);
+ Assert.IsNull(passphrase);
+ }
+ }
+}
\ No newline at end of file