Here's the post-build event PowerShell solution I've come up with (a workaround for users running in a Restricted execution policy is at the end of this answer).
1) Create a PowerShell script containing the following:
Param(
[Parameter(Mandatory=$True,Position=1)]
[string] $projPath,
[Parameter(Mandatory=$True,Position=2)]
[string] $debugScriptPath
)
# Setup new data types and .NET classes to get EnvDTE from a specific process
Add-Type -TypeDefinition @"
using System;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Runtime.InteropServices.ComTypes;
using System.Text.RegularExpressions;
public static class NTDLL
{
[StructLayout(LayoutKind.Sequential)]
public struct PROCESS_BASIC_INFORMATION
{
public IntPtr Reserved1;
public IntPtr PebBaseAddress;
public IntPtr Reserved2_0;
public IntPtr Reserved2_1;
public IntPtr UniqueProcessId;
public UIntPtr ParentUniqueProcessId;
}
public static UInt32 GetParentProcessID(IntPtr handle)
{
PROCESS_BASIC_INFORMATION pbi = new PROCESS_BASIC_INFORMATION();
Int32 returnLength;
UInt32 status = NtQueryInformationProcess(handle, IntPtr.Zero, ref pbi, Marshal.SizeOf(pbi), out returnLength);
if (status != 0)
return 0;
return pbi.ParentUniqueProcessId.ToUInt32();
}
[DllImport("ntdll.dll")]
private static extern UInt32 NtQueryInformationProcess(IntPtr processHandle, IntPtr processInformationClass, ref PROCESS_BASIC_INFORMATION processInformation, Int32 processInformationLength, out Int32 returnLength);
}
public static class OLE32
{
[DllImport("ole32.dll")]
public static extern Int32 CreateBindCtx(UInt32 reserved, out IBindCtx ppbc);
}
public class VisualStudioProcBinder
{
public static Object GetDTE(int processId)
{
Regex VSMonikerNameRegex = new Regex(@"!?VisualStudio\.DTE([\.\d]+)?:" + processId);
object runningObject = null;
IBindCtx bindCtx = null;
IRunningObjectTable rot = null;
IEnumMoniker enumMonikers = null;
try
{
Marshal.ThrowExceptionForHR(OLE32.CreateBindCtx(0, out bindCtx));
bindCtx.GetRunningObjectTable(out rot);
rot.EnumRunning(out enumMonikers);
IMoniker[] moniker = new IMoniker[1];
IntPtr numberFetched = IntPtr.Zero;
while (enumMonikers.Next(1, moniker, numberFetched) == 0)
{
IMoniker runningObjectMoniker = moniker[0];
string name = null;
try
{
if (runningObjectMoniker != null)
runningObjectMoniker.GetDisplayName(bindCtx, null, out name);
}
catch (UnauthorizedAccessException)
{
// Do nothing, there is something in the ROT that we do not have access to.
}
if (!string.IsNullOrEmpty(name) && VSMonikerNameRegex.IsMatch(name))
{
Marshal.ThrowExceptionForHR(rot.GetObject(runningObjectMoniker, out runningObject));
break;
}
}
}
finally
{
if (enumMonikers != null)
Marshal.ReleaseComObject(enumMonikers);
if (rot != null)
Marshal.ReleaseComObject(rot);
if (bindCtx != null)
Marshal.ReleaseComObject(bindCtx);
}
return runningObject;
}
}
"@
# Get the devenv.exe process that started this pre/post-build event
[Diagnostics.Process] $dteProc = [Diagnostics.Process]::GetCurrentProcess();
while ($dteProc -and $dteProc.MainModule.ModuleName -ne 'devenv.exe')
{
#Write-Host "$(${dteProc}.Id) = $(${dteProc}.MainModule.ModuleName)";
try { $dteProc = [Diagnostics.Process]::GetProcessById([NTDLL]::GetParentProcessID($dteProc.Handle)); }
catch { $_; $dteProc = $null; }
}
# Get dteCOMObject using the parent process we just located
$dteCOMObject = [VisualStudioProcBinder]::GetDTE($dteProc.Id);
# Get the project directory
$projDir = Split-Path $projPath -Parent;
# If the script path does not exist on its own - try using it relative to the project directory
if (!(Test-Path $debugScriptPath)) {
$debugScriptPath = "${projDir}\${debugScriptPath}";
}
#####################################################
# Finally, tweak the project
#####################################################
if ($dteCOMObject) {
# Get the project reference from DTE
$dteProject = $dteCOMObject.Solution.Projects | ? { $_.FileName -eq $projPath } | Select-Object -First 1;
# Set this project as the startup project
$startupProj = $dteCOMObject.Solution.Properties["StartupProject"].Value;
if ($startupProj -ne $dteProject.Name) {
$dteCOMObject.Solution.Properties["StartupProject"].Value = $dteProject.Name;
}
# Get the external debug program and arguments currently in use
$debugProg = $dteProject.Properties['WebApplication.StartExternalProgram'].Value;
$debugArgs = $dteProject.Properties['WebApplication.StartCmdLineArguments'].Value;
# If an external debug program is not set, or it is set to cmd.exe /C "<file path>"
# and "file path" points to a file that doesn't exist (ie. project path has changed)
# then correct the program/args
if (!$debugProg -or ($debugProg -eq $env:ComSpec -and $debugArgs -match '^\s*/C\s+("?)([^"]+)\1$'-and !(Test-Path $Matches[2]))) {
if (!$debugProg) { $dteProject.Properties['WebApplication.DebugStartAction'].Value = 2; } # 2 = run external program
$dteProject.Properties['WebApplication.StartExternalProgram'].Value = $env:ComSpec; # run cmd.exe
# pass "<project dir>\Testing\Debug.cmd" as the program to run from cmd.exe
$dteProject.Properties['WebApplication.StartCmdLineArguments'].Value = "/C `"${debugScriptPath}`"";
}
# Release our COM object reference
[Runtime.InteropServices.Marshal]::ReleaseComObject($dteCOMObject) | Out-Null;
}
2) Call the PowerShell script from your project post-build like:
powershell.exe -File "$(ProjectDir)script.ps1" "$(ProjectPath)" "$(ProjectDir)Testing\Debug.cmd"
The first parameter (after -File
) is the path to the script you created in step 1, the second parameter is the path to the project being built, and the third parameter (which your script will probably not have unless you're trying to do exactly what I am) is the path to the batch file/script that to be configured to run when debugging with an external program.
Workaround for users limited to running under a Restricted execution policy
(ie. powershell.exe -Command "Set-ExecutionPolicy Unrestricted"
does not work)
If PowerShell is locked into the Restricted execution policy you will not be able to run a PowerShell script either using powershell.exe -File script.ps1
commands or through dot-sourcing methods like powershell.exe -Command ". .\script.ps1"
. However, I've discovered that you can read a script into a variable and then run Invoke-Expression $ScriptContent
. (Seems odd to me that this works, but it does)
The workaround consists of:
1) Create a PowerShell script using the same content from above, but exclude the Param(...)
lines at the top.
2) Call the PowerShell script from your project post-build like:
powershell -Command "& { $projPath='$(ProjectPath)'; $debugScriptPath='$(ProjectDir)Testing\Debug.cmd'; Get-Content '$(ProjectDir)script.ps1' -Encoding String | ? { $_ -match '^^\s*[^^#].*$' } | %% { $VSTweaks += $_ + """`r`n"""; }; Invoke-Expression $VSTweaks; } }"
This reads contents of script.ps1
into a variable named $VSTweaks
(skipping lines that are only comments - ie. digital signature lines which cause problems in some scenarios), and then runs the script contents with Invoke-Expression
. The values that were passed into $projPath
and $debugScriptPath
through parameters in the original script are now set at the beginning of the call in powershell.exe -Command "& { $projPath ...}
. (If they are not set then the script will fail)
NOTE: Because the VS project post-build event contents are executed as a Windows batch file you have to escape a lot of characters that are special to Windows batch files. That explains some of the confusing character combinations in the script
- % = %%
- ^ = ^^
- " = """ (I'm honestly not sure why this is required, but it seems to be)
Tip
I've started wrapping the entire PowerShell call (<PowerShell commands>
in the example below) in try catch statements in order to make any PowerShell errors show up in the Visual Studio "Error List..." view when a build fails.
powershell -Command "& { try { <PowerShell commands> } catch { Write-Host "post-build : PowerShell error $^($_.Exception.HResult^) : $_"; exit $_.Exception.HResult; } }"
The resulting error message in VS looks like this:
