Introduction
Have you ever had the need to start Visual Studio from a piece of code and the code you needed to debug (the reason why you open Visual Studio instance in the first place) needs command line arguments. The case is that at the moment, devenv.exe does not let you pass arguments to your debug process. Here is where the trouble at my job began.
We needed a solution to be able to load Visual Studio but with supplied arguments. Else, we wouldn't be able to debug the process easily unless we also would run the start from out of debug to spot the current arguments and place them in the other solution by hand.
Route to Solution
To find a way where we would be able to receive arguments, several options were possible:
- Place them in the registry and read them out, which meant that each debug project needed its own settings and would always need work per project.
- Place them in the project.user file which actually offers a way to pass arguments towards your project. One of the problems with this method is, in case there are several possible startup objects within a solution, it's nearly impossible to know which to use unless you unravel the magic of a suo file.
- Find a way to tell the debug project a number in any way.
AppDomainManager vshost Style
When I was looking around in debug to see if we could find any loophole, we noticed that through reflection if loaded through vshost, the app domain manager would have an id of the Visual Studio it was controlled by.
This gave us the thought to see if we could place anything on the starting commandline of devenv
itself. It turned out that after /run solution/project, the arguments were ignored. At least so far as I could tell. This gave me the room I needed to be able to spoof something towards the debug process.
WindowBridge
Because of several reasons:
- Not shortening the allowed characters on a commandline
- To not hit the possibility a part of the added arguments were picked up by Visual Studio anyways
I decided to use an inbetweener. I created WindowBridge
, a standalone executable which had only 1 purpose. To pass through arguments and release them afterwards. The WindowBridge
works with an unshown window that accepts arguments and supplies an id number for it.
The Trio
To make work with the window bridge easier, I wanted a helper project that could place an argumentsline and receive its id and a helper project that could determine it needed to load an argumentsline from windowbridge and offers this as close as the normal commandline would be.
The helper project that places the argumentsline has been given a secondary purpose. To help in making the Visual Studio environment start easy. Through the versionnumbering inside the solutionfiles, it determines which version of Visual Studio to use (For Visual Studio 2017 and 2019, this is an educated guess, simply because if have none of both installed at the moment). This principle uses the information to load the Visual Studio it is meant for, or if not present, detects if there is a minimal Visual Studio version specified and seeks from new to old if any compatible version is present and starts that one. Also, the start routine has the possibility to wait for the end of Visual Studio which in case of usage will start devenv
with /runexit
. This result is that when the debug process is stopped, the Visual Studio environment is closed and the starting process knows it has ended.
The Works
In this section, I'll highlight parts of the code and explain why they are as they are.
Project WindowBridge
using System;
using System.Windows.Forms;
namespace WindowBridge
{
static class Program
{
[STAThread]
static void Main()
{
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
if (WindowBridgeFinder.GetWindowBridge() == IntPtr.Zero)
{
using (Bridge OurBridge = new Bridge())
{
Application.Run();
}
}
}
}
}
public class Bridge : NativeWindow, IDisposable
{
...
private List<uint> UniqueId = new List<uint>();
private List<KeyValuePair<uint, StringThingy>> Message =
new List<KeyValuePair<uint, StringThingy>>();
}
The WindowBridgeFinder
, which I'll explain below, guards us for running multiple instance. With running the Bridge from out of a using
block where inside the application is run, the dispose
will happen when the window receives the WM_DESTROY
windows message.
With making the bridge a NativeWindow
, you can easily made a listening window without the overload a WindowForm
gets. The UniqueId
list keeps track of what ids have been given out. The Message
list keeps track of which arguments belong to which uniqueid
.
If you look inside the window procedure of the Bridge
, you shall see no pointers are used on the message bus. The characters are communicated one by one, this to keep out of the troubles of interprocess memory. Because the order of window messages are not guaranteed, the characters are communicated by position.
WindowBridgefinder Routine, Part of All 3 Projects
using System;
using System.Collections.Generic;
using System.Text;
using System.Runtime.InteropServices;
using System.Diagnostics;
using static WindowBridge.WindowMessages;
namespace WindowBridge
{
internal static class WindowBridgeFinder
{
private delegate bool EnumThreadDelegate(IntPtr hWnd, IntPtr lParam);
[DllImport("user32.dll")]
private static extern bool EnumThreadWindows
(int dwThreadId, EnumThreadDelegate lpfn, IntPtr lParm);
[DllImport("user32.dll", EntryPoint = "GetClassName", CharSet = CharSet.Auto)]
private static extern int GetClassName(IntPtr hwnd, StringBuilder lpClassName, int nMaxCount);
[DllImport("user32.dll", CharSet = CharSet.Auto, EntryPoint = "SendMessage")]
private static extern IntPtr SendMessage(IntPtr hwnd, int wMsg, IntPtr wParam, IntPtr lParam);
private const int WM_USER = 0x400;
private static List<IntPtr> GetProcessWindowHandles(Process ToRetreiveItFor)
{
List<IntPtr> handles = new List<IntPtr>();
foreach (ProcessThread thread in ToRetreiveItFor.Threads)
{
EnumThreadWindows(thread.Id, (hWnd, lParam) =>
{
handles.Add(hWnd);
return true;
}, IntPtr.Zero);
}
return handles;
}
private static string GetClassName(IntPtr Handle)
{
string Result = null;
int WantedSize = 1000;
StringBuilder touse = new StringBuilder("", WantedSize + 5);
int Returned = GetClassName(Handle, touse, WantedSize + 2);
if (Returned > 0)
{
Result = touse.ToString();
}
return Result;
}
public static IntPtr GetWindowBridge()
{
IntPtr Result = IntPtr.Zero;
Process[] Listing = Process.GetProcesses();
foreach (Process Current in Listing)
{
if (Current.ProcessName.StartsWith("WindowBridge"))
{
List<IntPtr> Handles = GetProcessWindowHandles(Current);
foreach (IntPtr ToCheck in Handles)
{
string TheName = GetClassName(ToCheck);
bool StartsWith = false;
if (!ReferenceEquals(TheName, null))
{
StartsWith = TheName.StartsWith("WindowsForms");
}
if (StartsWith && TheName.Contains(WindowBridgeClassPart))
{
Result = ToCheck;
break;
}
}
}
if (Result != IntPtr.Zero)
{
break;
}
}
return Result;
}
}
}
The GetWindowBridge
function is a bit tricky. We need to find that process of the WindowBridge
and then need to find its listening window. We start with retrieving all processes. Within the process, we seek a process starting with WindowBridge
we then will retrieve the top window handles of the process and check if any of it meets the window we expect (having a classname starting with WindowsForms
and contain the word message, as we're based on the message
class). If all requirements have been found, we stop the seeking process and we know the WindowBridge
is up and running and which window handle to use to communicate with it.
Visual Studio Loader
public static bool RunDevEnv(string SolutionFile, string Arguments, bool WaitForExit){}
The Visual Studio loader contains 1 exposed function, RunDevEnv
for which the parameter Arguments
and WaitForExit
have been made optional through overloading.
The solutionfile
parameter contains the solution you want to load.
The arguments parameter contains the commandline you want to send. Take into account that also no argument is an argument. In the usage in the debug process, arguments can been set in the project properties. The reason for this also is that empty commandlines are being communicated.
The WaitForExit
parameter sets waiting for the end of the to be debugged process active or not.
The return value tells whether the execution of the Visual Studio environment succeeded or not. This will even be tried when WindowBridge
is not loaded. Which in case will result in not being able to retrieve the commandline on the debug process side.
public class Loader
{
...
static Loader()
{
...
StudioVersionInfo = new Dictionary<int, StudioVersionDescriptor>()
{
{ 8, new StudioVersionDescriptor("Version_9.00", "",
"# Visual Studio 2005")},
{ 9, new StudioVersionDescriptor("Version_10.00", "",
"# Visual Studio 2008")},
{ 10, new StudioVersionDescriptor("Version_11.00", "",
"# Visual Studio 2010")},
{ 11, new StudioVersionDescriptor("Version_12.00",
"StudioVersion_11", "# Visual Studio 12")},
{ 12, new StudioVersionDescriptor("Version_12.00",
"StudioVersion_12", "# Visual Studio 13")},
{ 14, new StudioVersionDescriptor("Version_12.00",
"StudioVersion_14", "# Visual Studio 15")},
{ 15, new StudioVersionDescriptor("Version_12.00",
"StudioVersion_15", "# Visual Studio 17")},
{ 16, new StudioVersionDescriptor("Version_12.00",
"StudioVersion_16", "# Visual Studio 19")},
{ 17, new StudioVersionDescriptor("Version_12.00",
"StudioVersion_17", "# Visual Studio 21")}
};
KnownRegistryLocations = new string[][]
{
new string[]
{
@"CLSID\{FE10D39B-A7F1-412c-83BA-D00788532ABB}\LocalServer32",
@"Wow6432Node\CLSID\{1B2EEDD6-C203-4d04-BD59-78906E3E8AAB}\LocalServer32",
@"Wow6432Node\CLSID\{BA018599-1DB3-44f9-83B4-461454C84BF8}\LocalServer32",
} ,
new string[]
{
@"VisualStudio.accessor.9.0\shell\Open\Command",
@"CLSID\{1BD51F8C-8CFC-4708-A88D-5690DE4D5C16}\LocalServer32",
@"Wow6432Node\CLSID\{1A5AC6AE-7B95-478C-B422-0E994FD727D6}\LocalServer32",
@"Wow6432Node\CLSID\{8B10A141-87EE-4A0F-823F-D79F5FF7B10A}\LocalServer32",
} ,
new string[]
{
@"VisualStudio.accessor.10.0\shell\Open\Command",
@"VisualStudio.sln.10.0\shell\Open\command",
@"Wow6432Node\CLSID\{656D8328-93F5-41a7-A48C-B42858161F25}\LocalServer32",
@"Wow6432Node\CLSID\{68681A5C-C22A-421d-B68B-5BA9D01F35C5}\LocalServer32",
@"Wow6432Node\CLSID\{6F5BF5E0-D729-46dd-891C-167FE3851574}\LocalServer32",
} ,
new string[]
{
@"VisualStudio.accessor.11.0\shell\Open\Command",
@"VisualStudio.sln.11.0\shell\Open\command",
@"Wow6432Node\CLSID\{059618E6-4639-4D1A-A248-1384E368D5C3}\LocalServer32",
@"Wow6432Node\CLSID\{7751A556-096C-44B5-B60D-4CC78885F0E5}\LocalServer32",
@"Wow6432Node\CLSID\{EB1425FE-3641-47AB-9484-32B62FC8B0B0}\LocalServer32",
} ,
new string[]
{
@"VisualStudio.accessor.12.0\shell\Open\Command",
@"VisualStudio.sln.12.0\shell\Open\command",
@"Wow6432Node\CLSID\{02CD4067-3D8F-4F9E-957F-F273804560C5}\LocalServer32",
@"Wow6432Node\CLSID\{3C0D7ACB-790B-4437-8DD2-815CA17C474D}\LocalServer32",
@"Wow6432Node\CLSID\{48AE9D34-2FE7-48A7-9D8A-A65534E3C20C}\LocalServer32",
} ,
new string[]
{
@"VisualStudio.accessor.14.0\shell\Open\Command",
@"VisualStudio.sln.14.0\shell\Open\command",
@"Wow6432Node\CLSID\{31F45B04-7198-45ED-A13F-F224A4A1686A}\LocalServer32",
@"Wow6432Node\CLSID\{A2FA2136-EB44-4D10-A1D3-6FE1D63A7C05}\LocalServer32",
@"Wow6432Node\CLSID\{CACE29C3-10A7-4B66-A8CA-82C1ECEC1FA3}\LocalServer32",
} ,
new string[]
{
@"VisualStudio.accessor.X.0\shell\Open\Command",
@"VisualStudio.sln.X.0\shell\Open\command",
}
};
#endregion InitMemory
for (int Counter = 0; Counter <= Studio20XX; Counter++)
{
bool Alter = (Counter == Studio20XX);
int Loop = Alter ? 3 : 1;
for (int ExtraLoop = 0; ExtraLoop < Loop; ExtraLoop++)
{
string[] Use = KnownRegistryLocations[Counter];
if (Alter)
{
string[] ToCopy = KnownRegistryLocations[Counter];
Use = new string[ToCopy.Length];
for (int SubCounter = 0; SubCounter < ToCopy.Length; SubCounter++)
{
Use[SubCounter] = ToCopy[SubCounter];
}
for (int ReplaceCounter = 0; ReplaceCounter < Use.Length; ReplaceCounter++)
{
Use[ReplaceCounter] =
Use[ReplaceCounter].Replace(".X.", "." + (15 + ExtraLoop).ToString() + ".");
}
}
Discover(Use, (string ToCheck) =>
{
bool Found = false;
FileVersionInfo Info =
FileVersionInfo.GetVersionInfo(ToCheck);
if (!ReferenceEquals(Info, null))
{
StudioVersionDescriptor BelongsTo;
if (StudioVersionInfo.TryGetValue(Info.FileMajorPart, out BelongsTo))
{
BelongsTo.Location = ToCheck;
FoundDevelopers.Add(Info.FileMajorPart, BelongsTo);
Found = true;
}
else
{
Debug.WriteLine(ToCheck + "
contains an unknown version if Visual Studio,
this program might be out of date.");
}
}
return Found;
});
}
}
if (!ReferenceEquals(FoundDevelopers, null) && (FoundDevelopers.Count > 0))
{
Dictionary<int, StudioVersionDescriptor>.ValueCollection Values =
FoundDevelopers.Values;
StudioVersionDescriptor[] Temp = new StudioVersionDescriptor[Values.Count];
Values.CopyTo(Temp, 0);
LastKnownStudioVersion = Temp[Temp.Length - 1];
}
}
...
}
...
}
In the static constructor of the Loader
class, a lot is being prepared to be able to select the correct version of Visual Studio. StudioVersionInfo
contains the known version ids together with the known solution file tags (guessed in case of 2017 and 2019). KnownRegistryLocations
contain what its name already suggest. Registry keys we know of Visual Studio uses. By this information, we've the possibility to determine its current installed location for the several versions, again 2017 and 2019 are guessed. I only work with local_machine/classroot keys so that we're not troubled with data not present because a different user has installed it and this user has not used it yet.
LastKnownStudioVersion
will contain the highest Visual Studio version we found. The version which will be used if for some reason a version to use has not been able to be determined.
internal static class ProcessSln
{
public static bool Process(string solutionFile, out string Version,
out string SubVersion, out string MinimumVisualStudioVersion, out string[] Projects)
{...}
}
The ProcessSln
class contains the actual logic to read out the contents of the solutionfile
and retrieve the information we want from it. It will retrieve version
, subversion
and the MinimumVisualStudioVersion
. It also results the found projects but at this time in this project, this is not further used. (I wrote this class actually for a buildnumber
updater routine for our bitten on trac).
When a project file has been supplied instead of a solution file, the Loader.cs will try to find a solution file where the project is a part of, to through that way determine the Visual Studio needed. It will look through the directory of the project file and one directory upper in the hierarchy.
CommandLineLoader
The commandline
loader is responsible for determining which commandline
to use and supply this in one liners towards its users. To be able to determine which commandline
to use it first has to determine whether we're under Visual Studio debug or not.
public static class CommandLine
{
...
private static readonly BindingFlags GetAll;
private static readonly int DevEnvId;
static CommandLine()
{
GetAll = BindingFlags.CreateInstance |
BindingFlags.FlattenHierarchy |
BindingFlags.GetField |
BindingFlags.Instance |
BindingFlags.InvokeMethod |
BindingFlags.NonPublic |
BindingFlags.Public |
BindingFlags.SetField |
BindingFlags.GetProperty |
BindingFlags.SetProperty |
BindingFlags.Static;
DevEnvId = GetDevenv();
}
private static int GetDevenv()
{
int DevenvId = 0;
AppDomain Current = AppDomain.CurrentDomain;
AppDomainManager Manager = null;
FieldInfo m_hpListenerField = null;
object m_hpListener = null;
Process Process = null;
FieldInfo m_procVSField = null;
if (!ReferenceEquals(Current, null))
{
Manager = Current.DomainManager;
}
if (!ReferenceEquals(Manager, null))
{
m_hpListenerField = (Manager.GetType().GetField("m_hpListener", GetAll));
}
if (!ReferenceEquals(m_hpListenerField, null))
{
m_hpListener = m_hpListenerField.GetValue(Manager);
}
if (!ReferenceEquals(m_hpListener, null))
{
m_procVSField = (m_hpListener.GetType()).GetField("m_procVS", GetAll);
}
if (!ReferenceEquals(m_procVSField, null))
{
Process = m_procVSField.GetValue(m_hpListener) as Process;
}
if (!ReferenceEquals(Process, null))
{
DevenvId = Process.Id;
}
return DevenvId;
}
...
}
GetDevEnv
is the function that determines the presence of Visual Studio. It does this by trying to retrieve its process id from the special app domain manager which is present if it is loaded through vshost. This route I've tested under 2005,2008,2010,2012,2013,2015. Only under 2008, I found the route not to work, but it could be that I had to save and compile that project first. Only if all steps are completed successfully and we've obtained the process id of the Visual Studio environment responsible for debugging our process, we'll say we found it.
private static string GetDevEnvArguments
(int DevEnvProcessId, bool WithExecutable, ref bool HadId)
{
string Result = string.Empty;
ManagementObjectSearcher commandlineSearcher =
new ManagementObjectSearcher
(
"SELECT CommandLine FROM Win32_Process WHERE ProcessId = " +
DevEnvProcessId.ToString()
);
String CommandLine = "";
bool Added = false;
foreach (ManagementObject commandlineObject in commandlineSearcher.Get())
{
if (!Added)
{
Added = true;
}
else
{
CommandLine += " ";
}
CommandLine += commandlineObject["CommandLine"] as string;
}
if (!string.IsNullOrWhiteSpace(CommandLine))
{
int pos = CommandLine.LastIndexOf(" ");
if (pos >= 0)
{
string Number = CommandLine.Substring(pos).TrimEnd();
int ArgumentsId;
if (int.TryParse(Number, out ArgumentsId))
{
HadId = true;
string DevArguments = Bridge.GetArguments(ArgumentsId);
if (WithExecutable)
{
string EnvCmd = Environment.CommandLine;
int Pos = EnvCmd.IndexOf(".exe", StringComparison.OrdinalIgnoreCase);
if (Pos >= 0)
{
DevArguments = EnvCmd.Substring(0, Pos + 4) + " " + DevArguments;
}
}
Result = DevArguments;
}
}
}
return Result;
}
With the found process id, the argument line meant for the debug process is retrieved.
First through WMI, the commandline
of the Visual Studio instance is retrieved. When this was successful, the last space on the line is seeked. If we've spoofed a number on it, that will be its location. We retrieve the last part of the line and try to convert it to an integer. If this succeeds, we ask the WindowBridge
the commandline
belonging to the found identifier. If the identifier was valid, we know we have the commandline
meant for our debug process. Depending on whether the retrieval is needed for the main(string[]), StartUpEventArgs
or main()
, we need to stick the executable name in front or not. WithExecutable
helps us do this. When all has been glued together, we finally have the commandline
to use.
public static class CommandLine
{
...
public static string[] GetCommandLine(string[] Current)
public static string GetCommandLine()
public static void GetStartupEventArguments(ref StartupEventArgs ToUpdate)
...
}
The public CommandLine
class has three different functions, each meant for a different scenario.
The string[] GetCommandLine(string[] Current)
is meant for direct usage after main(string[] args)
:
args = WindowBridge.CommandLine.GetCommandLine(Args);
The string GetCommandLine()
is meant as replacement for the System.Environment.Commandline
function, the placed commandline
will be cached.
The void GetStartupEventArguments(ref StartupEventArgs ToUpdate)
is meant for the overload of Application.OnStartup
in the PresentationFramework
.
...
protected override void OnStartup(StartupEventArgs e)
{
WindowBridge.CommandLine.GetStartupEventArguments(ref e);
}
...
The StartupEventArgs _args
variable is updated through reflection. The class itself stops retrieving new information once the argument has contents which solve the possible issue of losing our work.
Sum Up
Putting it all together, we've a Visual Studio loader that allows us to easily start a debugging process for a specified solution (or project) file. With the running of WindowBridge
and using CommandLineLoader
inside the code of the debug process, we get the possibility to run a debugging process with supplying command line arguments. The amount of change needed in source code for the debug processes are minimized to one liners.
As long as the startup project has been saved in the solution, this methodology works for any possible startupobject
in a solution, after initial preparation, codes will never change nor shall its user file for passing arguments.
1 Loader, 1 Bridge, 1 Data retriever.
Points of Interest
By creating this loader, I refreshed my old knowledge of the windowproc
and window classes, I played a bit once more with WMI and looked more closely to how commandline
arguments actually are broken into pieces before we can use it as Args[]
. I've updated my knowledge on what a solution file contains but especially does not contain, as for example the project to start.
History