How to Control Visual Studio from an external application

 
 
  • Gérald Barré

There are many scenarios where you need to get information from running Visual Studio instances. For example, when building a Git client, you might suggest repositories matching the open solution, detect unsaved files to display a warning, or close the current solution and open a new one. All of this is possible because Visual Studio exposes COM interfaces.

Let's create a console application that lists the running Visual Studio instances, their versions, and the currently open solution.

Shell
dotnet new console

First, add the EnvDTE80 package, which provides the types needed to interact with Visual Studio.

Shell
dotnet add package EnvDTE80

The csproj file should look like the following:

csproj (MSBuild project file)
<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>

To enumerate the running Visual Studio instances, use the GetRunningObjectTable method, which lists all registered COM instances. Visual Studio registers its instances in the Running Object Table. For each moniker, you can retrieve its display name using CreateBindCtx and GetDisplayName. For Visual Studio instances, the moniker name follows the pattern !VisualStudio.DTE.{Version}.

VisualStudioInstance.cs (C#)
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:

VisualStudioInstance.cs (C#)
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:

Program.cs (C#)
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!

Follow me:
Enjoy this blog?