How to Control Visual Studio from an external application
There are multiple use cases where you need to get information from running instances of Visual Studio. For instance, if you create a git client, you may suggest the repositories that correspond to the opened solution, or you want to know if a file is not saved in the editor and show a warning. Maybe you need to close the current solution and open a new one. All of this is possible with Visual Studio as it exposes COM interfaces!
Let's create a console application that displays the list of opened Visual Studio instances with their version and currently opened solutions.
dotnet new console
First, you need to add the EnvDTE80
package. This package contains the code to interact with Visual Studio.
dotnet add package EnvDTE80
The csproj
file should look like the following:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net6.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="EnvDTE80" Version="17.2.32505.113" />
</ItemGroup>
</Project>
Then, you can enumerate the opened Visual Studio instances by using the GetRunningObjectTable
method. This method lists registered COM instances. Visual Studio registers its instances in the RunningObjectTable
COM object. For each moniker from this table, you can get the name of the object by using CreateBindCtx
and GetDisplayName
. In the case of Visual Studio, the moniker name is !VisualStudio.DTE.{Version}
.
using System.Runtime.InteropServices;
using System.Runtime.InteropServices.ComTypes;
using System.Runtime.Versioning;
using EnvDTE;
using EnvDTE80;
using Thread = System.Threading.Thread;
[SupportedOSPlatform("windows5.0")]
internal sealed partial class VisualStudioInstance
{
private const int MaxRetryCount = 10;
private const int SleepTimeAtEachRetry = 10;
const string DteName = "!VisualStudio.DTE.";
private const uint S_OK = 0;
private readonly DTE2 _dte2;
private VisualStudioInstance(DTE2 dte2)
{
_dte2 = dte2 ?? throw new ArgumentNullException(nameof(dte2));
}
public static IEnumerable<VisualStudioInstance> GetInstances()
{
var uret = GetRunningObjectTable(0, out IRunningObjectTable runningObjectTable);
if (uret != S_OK)
yield break;
runningObjectTable.EnumRunning(out IEnumMoniker monikerEnumerator);
if (monikerEnumerator != null)
{
foreach (IMoniker moniker in EnumerateMonikers(monikerEnumerator))
{
try
{
var bindContextResult = CreateBindCtx(0, out IBindCtx ctx);
if (bindContextResult != S_OK)
continue;
moniker.GetDisplayName(ctx, null, out var objectName);
Marshal.ReleaseComObject(ctx);
if (objectName.StartsWith(DteName, StringComparison.Ordinal))
{
var getObjectResult = runningObjectTable.GetObject(moniker, out var temp);
if (getObjectResult == S_OK)
{
var dte = (DTE2)temp;
VisualStudioInstance? instance = null;
try
{
if (dte != null)
{
_ = dte.FileName; // dummy call to ensure DTE is responsive
instance = new VisualStudioInstance(dte);
}
}
catch
{
instance = null;
}
if (instance != null)
{
yield return instance;
}
}
}
}
finally
{
Marshal.ReleaseComObject(moniker);
}
}
}
}
private static IEnumerable<IMoniker> EnumerateMonikers(IEnumMoniker enumerator)
{
const int MonikerBunchSize = 10;
var monikers = new IMoniker[MonikerBunchSize];
IntPtr fetchCountReference = Marshal.AllocHGlobal(sizeof(int));
try
{
int nextResult;
do
{
nextResult = enumerator.Next(MonikerBunchSize, monikers, fetchCountReference);
var fetchCount = Marshal.ReadInt32(fetchCountReference);
for (var i = 0; i < fetchCount; i++)
{
yield return monikers[i];
}
}
while (nextResult == S_OK);
}
finally
{
Marshal.FreeHGlobal(fetchCountReference);
}
}
[DllImport("ole32.dll", EntryPoint = "GetRunningObjectTable")]
private static extern uint GetRunningObjectTable(uint res, out IRunningObjectTable runningObjectTable);
[DllImport("ole32.dll", EntryPoint = "CreateBindCtx")]
private static extern uint CreateBindCtx(uint res, out IBindCtx ctx);
}
You can extend the VisualStudioInstance
class with helper methods to interact with the DTE2
instance. Here are some useful examples:
internal sealed partial class VisualStudioInstance
{
public string? SolutionFullPath
{
get
{
if (TryExecuteDevEnvCommand(() => _dte2.Solution.FullName, out var fullName) && !string.IsNullOrEmpty(fullName))
return fullName;
return null;
}
}
public string? Version
{
get
{
if (TryExecuteDevEnvCommand(() => _dte2.Version, out var version))
return version;
return null;
}
}
public bool TryOpenSolution(string solutionPath)
{
return TryExecuteDevEnvCommand(() => _dte2.Solution.Open(solutionPath));
}
public bool TryCloseSolution()
{
return TryExecuteDevEnvCommand(() => _dte2.Solution.Close(SaveFirst: true));
}
public bool HasUnsavedChanges()
{
foreach (Document item in _dte2.Documents)
{
if (!item.Saved)
return true;
}
return false;
}
public void TryAttachToProcess(int processId)
{
TryExecuteDevEnvCommand(() =>
{
var process = _dte2.Debugger.LocalProcesses;
foreach (Process item in process)
{
if (item.ProcessID == processId)
{
item.Attach();
break;
}
}
});
}
private static bool TryExecuteDevEnvCommand(Action devEnvCommand)
{
return TryExecuteDevEnvCommand(() =>
{
devEnvCommand();
return 0;
}, out _);
}
private static bool TryExecuteDevEnvCommand<T>(Func<T> devEnvCommand, out T? value)
{
var count = 0;
while (count++ < MaxRetryCount)
{
try
{
value = devEnvCommand();
return true;
}
catch (COMException)
{
Thread.Sleep(SleepTimeAtEachRetry);
}
catch (InvalidCastException)
{
break;
}
}
value = default;
return false;
}
}
Finally, you can update the Program.cs
file to use the VisualStudioInstance
class:
foreach (var instance in VisualStudioInstance.GetInstances().ToArray())
{
Console.Write($"{instance.Version}: {instance.SolutionFullPath}");
if (instance.HasUnsavedChanges())
{
Console.Write(" *");
}
Console.WriteLine();
}
#Additional resources
Do you have a question or a suggestion about this post? Contact me!