Sunday, July 1, 2007

Macros That Display Windows Screens. A journey to the GAC and beyond.

In today's adventure Kennon will try to implement a macro for adding regions into his source code. This is a fairly simple macro to write but hold onto your seats because this is the most dangerous adventure to date. Today's journey will travel to the GAC and beyond! First of all, if you have not yet discovered macros consider reading p. 171+ in Programming Microsoft Visual Basic 2005: The Language by Francesco Balena. Here is a very brief introduction if you don't happen to have Balena's excellent book handy. A macro allows you to record repetitive keystrokes or other actions and then replay those strokes again and again as needed. Macros can be created, edited and executed using the "Macro Explorer" window which can be launched from Tools-->Macros. Here is an example that inserts a code region into a source file (for whatever reason I have the hardest time typing #Region ""):

Public Sub CreateRegion(Optional ByVal regionName As String = "")
  If String.IsNullOrEmpty(regionName) Then
    regionName = InputBox("Region Name:", "Region Name:", "Imports")

  End If

  If regionName.Length > 0 Then
    Dim sel As TextSelection = DTE.ActiveDocument.Selection
    Dim textToWorkWith As String = sel.Text
    If textToWorkWith.EndsWith(vbCrLf) Then
      textToWorkWith = textToWorkWith.Substring(0, textToWorkWith.Length - 2)
    End If
    textToWorkWith = "#Region """ & regionName & """" & vbCrLf & _ textToWorkWith & vbCrLf & "#End Region" & vbCrLf
    sel.Text = textToWorkWith
  End If
End Sub

This seems like a lot of trouble to go through just to avoid typing #Region but one recommendation I received on Macro writing was, "Start small." Since writing this macro I have expanded my horizons to write a macro that writes my name, the date and check-in comments in my source code, then optionally checks the file into my version control system and then optionally closes the source file. It is a real timesaver and means that I'll never have to wonder what the date is again! Because macros allow you to pass 1 (only) optional string (no other data types are supported) parameter as well as display input boxes and message boxes they offer a great deal of flexibility. Furthermore, macros are written in VB.Net so you have broad access to the .Net Framework. Macros can also be executed from the command window by typing ">Macros.MyMacros.. " where ModuleName is the module name where your macro method was written. The module name is usually "RecordingModule" when you are first starting out but once you start to write many more macros you might start to group different macros into different modules. If your macro accepts an optional argument you can pass that argument as the value of . Note that this value does not need to be enclosed in quotation marks. One workaround for the single parameter limitation is to pass multiple values concatenated together all as a single value and do some parsing to break apart the separate values. Here is an example of how I can use the command window to call my "CreateRegion" macro and pass "Imports" as the optional parameter. >Macros.MyMacros.RecordingModule.CreateRegion Imports When you look at that syntax you have to scratch your head and say, "That takes too long. Why doesn't this guy just type #Region "Imports" and get on with his life?" That is a pretty valid question but because the command window allows for aliases to be assigned to macros (or any other IDE function) I can type the following to quickly assign "imp" as my alias for "Macros.MyMacros.RecordingModule.CreateRegion Imports". Here is how: >alias imp Macros.MyMacros.RecordingModule.CreateRegion Imports Now you might be saying, "That's pretty fast but accessing the command window is awkward and slow. Is there a faster way?" Luckily, there is. Macros can be assigned to keyboard shortcuts (Tools-->Options then the "Keyboard" option). On this screen you can quickly find the command you want by typing a filter into the "Show commands containing:" textbox and then assign a shortcut by putting the focus in the "Press shortcut keys:" textbox and typing your desired combination and clicking "Assign." Well, this is great but there is no way for me to pass a parameter (e.g. Imports) to my CreateRegion method so in this case this is a step backward although in most cases these keyboard shortcuts are very helpful. By the way, the keyboard shortcut assignment screen is a great screen for searching for IDE command names that you will need to reference when writing macros. For example, within the VS Macro Editor you can execute any IDE command (I think?) by coding DTE.ExecuteCommand(""). For example, DTE.ExecuteCommand("File.Close") will close a file. If you are not sure whether it should be "File.Exit" or "File.Close" the keyboard shortcut screen is a great way to find out!

OK, back to our adventure. Although I like the ability to simply type "imp" in the command window and get my imports region the fact is there are about 20 common regions that I need to create regularly. I could create command window aliases for each but then I'd have 20 aliases to remember. I could write 20 different macros and assign 20 different shortcut keys (ouch!). I could give up on using macros and create 20 code snippets (messy). Then the obvious occurred to me, "I could create a combo box so that I could just select the desired region name!" That was so obvious that you must be thinking, "Duh, there is no way this guy should be a programmer!" Well, here's the catch. Apparently (perhaps for security reasons) the entire macros team at Microsoft never saw this as a possibility either because there is no documented way to add a windows screen to a macro project. The work around is simple, or so I thought. I can just write my combo box screen in a different assembly and then reference it from my Macro project and this where this adventure heads into uncharted waters.

First, I wrote my other application (MacroHelpers.dll) that contains one public dialog screen that has a combo box with all of my region names and compiled my assembly. Now, all I needed to do was add the reference to MacroHelpers.dll from within my macros project and I would be all set. WRONG! It turns out macro projects only allow the addition of references that show up in the .NET reference tab and my MacroHelpers.dll definitely did not qualify as something special enough to be listed in the as a .Net reference.

OK, it was time for plan B. I'll sign MacroHelpers.dll using a .snk file (see the "Signing" tab in the "My Project" screen) and register it in the GAC using GacUtil.exe. That should do it! Plan B sounded more fun anyway because it would give me a chance to add post build events (see the "Build Events" button on the "Compile" tab of the "My Project" screen). So, this was my first task. How could I add a build event that would register my DLL using GacUtil.exe? It turned out to be fairly simple based on everything I read online here was the suggestion:

gacutil /i $(TargetPath)

Well, this blew up with a startlingly loud bang and returned this message:
The command "gacutil /i "C:\Developer Resources\MacroHelpers\bin\Debug\MacroHelpers.dll"" exited with code 9009.

Part of the reason this blew up was my "TargetPath" included spaces (e.g. "Developer Resources") so I had to enclose the keyword $(TargetPath) in quotes. Like so, gacutil /i "$(TargetPath)". Well, this still blew up! I thought, maybe it needs to say gacutil.exe /i "$(TargetPath)" but that still failed. Luckily, this page provided some good advice. Basically, it stated that gacutil may not be accessible without path information and it recommended the following:
"$(DevEnvDir)..\..\SDK\v2.0\Bin\gacutil.exe" /i "$(TargetPath)"

Ah, sweet success! Now, that I had registered by strongly named assembly and restarted VS and the VS Macro Editor I should finally see MacroHelpers.dll in the .Net references list! WRONG! My assembly was still not there but at this point I was not going to give up and face extinction. No matter how simple it is to type #Region!

It was time for plan C! OK, I didn't start with a plan C but now I needed to make one up. I got some good advice from the codeproject which sent me to KB 306149 which summarized to: To display your assembly in the Add Reference dialog box, you can add a registry key, such as the following, which points to the location of the assembly:

That sounded great and the fact that is was a Microsoft knowledgebase item really got my hopes up. My optimism was soon squashed because this did not work either. I made the entry and restarted VS and restarted VS and maybe even restarted it a 3rd time but it looked like plan C was not going to work either.

It was time for plan D! I was starting to worry that I might run out of letters for my plans and have to resort to the greek alphabet when finally I took this one drastic step and changed my build events to this (3 lines, ignore any line breaks that were forced by your browser):
"$(DevEnvDir)..\..\SDK\v2.0\Bin\gacutil.exe" /u

COPY "$(TargetPath)" "C:\WINDOWS\Microsoft.NET\Framework\v2.0.50727\$(TargetFileName)"

"$(DevEnvDir)..\..\SDK\v2.0\Bin\gacutil.exe" /i "C:\WINDOWS\Microsoft.NET\Framework\v2.0.50727\$(TargetFileName)"

OK, for those of you that didn't read that carefully, I decided to copy my lowly little MacroHelpers.dll into the C:\WINDOWS\Microsoft.NET\Framework\v2.0.50727 directory! Now, I'm not necessarily recommending that you try that at work unless you cannot be fired but it worked for me! Now my macro project could reference my MacroHelpers.dll and I could start launching with windows screens from within my macros! Ah, sweet success.

I guess when you are battling extinction it is good to have a "Never say 'Die' attitude." Today, it paid off.

Update on 07/16/07 - After reading page 105 of Inside Microsoft Visual Studio .Net (2003 edition) by Johnson, Skibo and Young I found the correct way to make an assembly available as a reference within a Macro project. The correct thing to do (and avoid the headaches I experienced) is to copy the DLL to the C:\Program Files\Microsoft Visual Studio 8\Common7\IDE\PublicAssemblies\ directory. So the correct build event syntax would be:

copy "$(TargetPath)" "C:\Program Files\Microsoft Visual Studio 8\Common7\IDE\PublicAssemblies\$(TargetFileName)"

There are a couple of follow ups to add as well:
1) It is important to mention that the first time I ran the macro the dialog screen displayed behind VS05 so I had to use Alt+Tab to find it. VS gave me the nasty "Keep Waiting" message but eventually I found the screen. To fix this, simply change the dialog screen's "TopMost" property to "True" and rebuilt the helper application.
2) It is possible that my usage of a macro for this entire experiment is inappropriate. In addition to writing macros you can also write Add-Ins and perhaps those were designed with more user interaction in mind but the resources I had on Adds-Ins stated that they were more difficult and involved registration in the GAC and when I started this little project I had no idea that my adventure would eventually lead to the GAC and beyond.

3) Part of the reason for being so stubborn on this was that I wanted to standardize region names across my development team. So the combo box implementation will help with this goal.

Thanks for sharing this adventure with me.

No comments: