Skip to content

Instantly share code, notes, and snippets.

@kevinoid
Created June 26, 2020 02:35
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save kevinoid/19f97146bb2a032d56cb04266f8d69fa to your computer and use it in GitHub Desktop.
Save kevinoid/19f97146bb2a032d56cb04266f8d69fa to your computer and use it in GitHub Desktop.
Script to (re-)generate .designer files for .aspx, .ascx, .master, .resx, .settings, and other files which generate code at design time.
##!/usr/bin/env pwsh
<#
.SYNOPSIS
Generate .designer files for a project or solution.
.NOTES
Copyright 2020 Kevin Locke <kevin@kevinlocke.name>
Available under the MIT License: https://opensource.org/licenses/MIT
#>
[CmdletBinding(
DefaultParameterSetName='Path',
SupportsShouldProcess)]
Param(
[Parameter(HelpMessage='Literal path of solution or project for which to generate designer files',
Mandatory=$true,
ParameterSetName='LiteralPath')]
[ValidateNotNullOrEmpty()]
[string]$LiteralPath,
[Parameter(HelpMessage='Path of solution or project for which to generate designer files',
Mandatory=$true,
ParameterSetName='Path',
Position=0)]
[ValidateNotNullOrEmpty()]
[string]$Path,
[Parameter(HelpMessage='Overwrite existing .designer files')]
[Switch]
$Force
)
Set-StrictMode -Version Latest
if ([Threading.Thread]::CurrentThread.GetApartmentState() -ne [Threading.ApartmentState]::STA) {
throw [InvalidOperationException]'Must be run from single-threaded apartment. Use -STA PowerShell param.'
}
# Resolve and ensure path exists
if ($LiteralPath) {
$item = Get-Item -LiteralPath $LiteralPath -ErrorAction Stop
} else {
$item = Get-Item -Path $Path -ErrorAction Stop
}
if ($item -isNot [IO.FileInfo]) {
throw [ArgumentException]'Path must be a file.'
}
$itemPath = $item.FullName
if ($itemPath -notLike '*.sln' -and $itemPath -notLike '*.*proj') {
throw [ArgumentOutOfRangeException]'Path must be .sln or .*proj file.'
}
$envDteAssembly = [Reflection.Assembly]::LoadWithPartialName('EnvDTE')
$vsShellAssembly = [Reflection.Assembly]::LoadWithPartialName('Microsoft.VisualStudio.Shell')
$vsShellInterop8Assembly = [Reflection.Assembly]::LoadWithPartialName('Microsoft.VisualStudio.Shell.Interop.8.0')
$vsShellInteropAssembly = [Reflection.Assembly]::LoadWithPartialName('Microsoft.VisualStudio.Shell.Interop')
$vsOleInteropAssembly = [Reflection.Assembly]::LoadWithPartialName('Microsoft.VisualStudio.OLE.Interop')
[void][Reflection.Assembly]::LoadWithPartialName('VSLangProj')
# On my system, Add-Type with 'Microsoft.VisualStudio.Shell' causes
# FileNotFoundException by trying to load Version=2.0.0.0. I can't figure out
# why. Since LoadWithPartialName works fine, use the version it resolved.
# Do the same for all assemblies to ensure all code uses the same versions.
[string[]]$refAssemblies = @(
$envDteAssembly.FullName,
$vsShellInterop8Assembly.FullName,
$vsShellInteropAssembly.FullName,
$vsOleInteropAssembly.FullName,
$vsShellAssembly.FullName
)
# Note: May be defined from a previous run (e.g. running from PowerShell ISE)
if (-not ('DTEUtils' -as [Type])) {
Add-Type -ReferencedAssemblies $refAssemblies @'
using System;
using System.Runtime.InteropServices;
using EnvDTE;
using Microsoft.VisualStudio.Shell.Interop;
using OLEInterop = Microsoft.VisualStudio.OLE.Interop;
using Shell = Microsoft.VisualStudio.Shell;
public static class DTEUtils
{
/// <summary>
/// Get a <see cref="Shell.ServiceProvider"/> for a given <see cref="DTE"/>.
/// </summary>
/// <remarks>
/// <c>New-Object ServiceProvider $dte</c> throws:
/// Cannot find an overload for "ServiceProvider" and the argument count: "1".
/// Presumably because it can't infer the type and ComObject can't be cast.
/// This method can be used as a workaround.
/// </remarks>
public static Shell.ServiceProvider GetServiceProvider(DTE dte)
{
// Create Shell.ServiceProvider using OLE.Interop.IServiceProvider
// interface of DTE object, as documented in:
// https://docs.microsoft.com/en-us/visualstudio/extensibility/how-to-get-a-service#getting-a-service-from-the-dte-object
// https://www.mztools.com/articles/2007/MZ2007015.aspx
// https://weblogs.asp.net/cazzu/GetServiceFromDTE
return new Shell.ServiceProvider((OLEInterop.IServiceProvider)dte);
}
/// <remarks>Convenience method for PowerShell.</remarks>
public static Shell.ServiceProvider GetServiceProvider(object dte)
{
return GetServiceProvider((DTE)dte);
}
}
/// <summary>
/// Wrapper for calling <see cref="IVsExtensibility3"/> methods from PowerShell.
/// </summary>
public class VsExtensibilityWrapper
{
private readonly IVsExtensibility3 vsExtensibility;
public VsExtensibilityWrapper(DTE dte)
: this(DTEUtils.GetServiceProvider(dte))
{
}
public VsExtensibilityWrapper(System.IServiceProvider serviceProvider)
{
// Get IVSExtensibility3 from IVsExtensibility as in
// https://github.com/dotnet/project-system/issues/1020
this.vsExtensibility =
(IVsExtensibility3)serviceProvider.GetService(typeof(IVsExtensibility));
if (this.vsExtensibility == null)
{
throw new InvalidOperationException("ServiceProvider has no IVsExtensibility");
}
}
/// <summary>Constructs a VsExtensibilityWrapper from a DTE.</summary>
/// <remarks>Convenience method for PowerShell.</remarks>
public static VsExtensibilityWrapper FromDTE(object dte)
{
return new VsExtensibilityWrapper((DTE)dte);
}
/// <summary>
/// Set DTE state to indicate that an automation function is executing.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="VsShellUtilities.IsInAutomationFunction"/> (or an equivalent)
/// is used to determine whether to prompt the user. Calling this function
/// indicates that an automation function is running and the uesr should not
/// be prompted.
/// </para>
/// <para>
/// <c>$vsExt.EnterAutomationFunction()</c> in PS throws MethodNotFound:
/// Method invocation failed because [System.__ComObject] does not contain a method named 'EnterAutomationFunction'.
/// even though <c>$vsExt -is [IVsExtensibility3]</c> is <c>$true</c>.
/// This method can be used as a workaround.
/// </para>
/// </remarks>
public void EnterAutomationFunction()
{
int hresult = this.vsExtensibility.EnterAutomationFunction();
Marshal.ThrowExceptionForHR(hresult);
}
/// <summary>
/// Set DTE state to indicate that an automation function is not executing.
/// </summary>
/// <seealso cref="EnterAutomationFunction"/>
public void ExitAutomationFunction()
{
int hresult = this.vsExtensibility.ExitAutomationFunction();
Marshal.ThrowExceptionForHR(hresult);
}
}
'@
}
# Note: May be defined from a previous run (e.g. running from PowerShell ISE)
if (-not ('MessageFilter' -as [Type])) {
# From https://docs.microsoft.com/en-us/previous-versions/ms228772(v=vs.140)
# IOleMessageFilter => Microsoft.VisualStudio.OLE.Interop.IMessageFilter
Add-Type -ReferencedAssemblies $vsOleInteropAssembly.FullName @'
using System.Runtime.InteropServices;
using Microsoft.VisualStudio.OLE.Interop;
public class MessageFilter : IMessageFilter
{
//
// Class containing the IMessageFilter
// thread error-handling functions.
// Start the filter.
public static void Register()
{
IMessageFilter newFilter = new MessageFilter();
IMessageFilter oldFilter = null;
CoRegisterMessageFilter(newFilter, out oldFilter);
}
// Done with the filter, close it.
public static void Revoke()
{
IMessageFilter oldFilter = null;
CoRegisterMessageFilter(null, out oldFilter);
}
//
// IMessageFilter functions.
// Handle incoming thread requests.
uint IMessageFilter.HandleInComingCall(uint dwCallType,
System.IntPtr hTaskCaller, uint dwTickCount, INTERFACEINFO[]
lpInterfaceInfo)
{
//Return the flag SERVERCALL_ISHANDLED.
return 0;
}
// Thread call was rejected, so try again.
uint IMessageFilter.RetryRejectedCall(System.IntPtr
hTaskCallee, uint dwTickCount, uint dwRejectType)
{
if (dwRejectType == 2)
// flag = SERVERCALL_RETRYLATER.
{
// Retry the thread call immediately if return >=0 &
// <100.
return 99;
}
// Too busy; cancel call.
return 0xFFFFFFFF;
}
uint IMessageFilter.MessagePending(System.IntPtr hTaskCallee,
uint dwTickCount, uint dwPendingType)
{
//Return the flag PENDINGMSG_WAITDEFPROCESS.
return 2;
}
// Implement the IMessageFilter interface.
[DllImport("Ole32.dll")]
private static extern int
CoRegisterMessageFilter(IMessageFilter newFilter, out
IMessageFilter oldFilter);
}
'@
}
# Note: COM interface types are not used for parameters due to inability to
# cast __ComObject in PowerShell.
# See https://stackoverflow.com/q/9036551
<#
.SYNOPSIS
Determines whether the path of a .designer file for a given project item exists.
#>
Function Test-DesignerItem() {
Param(
[Parameter(HelpMessage='Project item', Mandatory=$true, Position=0)]
# [EnvDTE.ProjectItem]
$ProjectItem
)
$fileCount = $ProjectItem.FileCount
for ($i = 0; $i -lt $fileCount; $i++) {
$fileName = $ProjectItem.FileNames($i)
$dotIndex = $fileName.LastIndexOf('.')
if ($dotIndex -gt 0) {
$designerName = $fileName.Insert($dotIndex, '.designer')
if (Test-Path -LiteralPath $designerName) {
return $true
}
}
}
return $false
}
<#
.SYNOPSIS
Determines whether the path of a given project item exists.
#>
Function Test-ProjectItem() {
Param(
[Parameter(HelpMessage='Project item', Mandatory=$true, Position=0)]
# [EnvDTE.ProjectItem]
$ProjectItem
)
$fileCount = $ProjectItem.FileCount
for ($i = 0; $i -lt $fileCount; $i++) {
$fileName = $ProjectItem.FileNames($i)
if (Test-Path -LiteralPath $fileName) {
return $true
}
}
return $false
}
<#
.SYNOPSIS
Removes .designer project items from in a given project item.
#>
Function Remove-Designer() {
[CmdletBinding(SupportsShouldProcess)]
Param(
[Parameter(HelpMessage='Project items', Mandatory=$true, Position=0)]
# [EnvDTE.ProjectItems]
$ProjectItems,
[Parameter(HelpMessage='Remove project items which exist in addition to those that don''t')]
[Switch]
$Force,
[Parameter(HelpMessage='Recurse into sub-projects')]
[Switch]
$Recurse
)
foreach ($projectItem in $ProjectItems) {
if ($Recurse) {
if ($projectItem.SubProject) {
Remove-Designer $projectItem.SubProject.ProjectItems `
-Force:$Force -Recurse:$Recurse
}
}
# Note: Although this is recursion, it is not covered by the -Recurse
# param because dependent files usually appear as ProjectItems below
# the item from which they are generated. If this were disabled, the
# command would be mostly useless.
$projectItems = $projectItem.ProjectItems
if ($null -ne $projectItems -and $projectItems.Count -gt 0) {
Remove-Designer $projectItems -Force:$Force -Recurse:$Recurse
}
# TODO: Limit to files which are DependentUpon non-designer file?
# TODO: Skip AutoGen files (without -Force)?
# All examples I'm aware of are the result of CustomTool and
# would be overwritten when CustomTool is run.
if ($projectItem.Name -notLike '*.designer.*') {
Write-Debug "Not removing $($projectItem.Name): Not named *.designer.*"
} elseif ($projectItem.Kind -ne [EnvDTE.Constants]::vsProjectItemKindPhysicalFile) {
Write-Debug "Not removing $($projectItem.Name): Not a physical file."
} elseif (-not $Force -and (Test-ProjectItem $projectItem)) {
Write-Debug "Not removing $($projectItem.Name): File exists and -Force not given."
} elseif ($PSCmdlet.ShouldProcess($projectItem.Name)) {
Write-Verbose "Removing $($projectItem.Name)"
$projectItem.Delete()
}
}
}
<#
.SYNOPSIS
Converts the open solutions to a Web Application Project.
#>
Function ConvertTo-WebApplication() {
[CmdletBinding(SupportsShouldProcess)]
Param(
[Parameter(HelpMessage='Visual Studio automation object', Mandatory=$true, Position=0)]
# [EnvDTE80.DTE2]
$DTE,
[Parameter(HelpMessage='Visual Studio extensibility wrapper')]
[VsExtensibilityWrapper]$VsExtensibility
)
# Note: Project.ConverttoWebApplication operates on the selected item(s).
# Could select a single project or file with code like
# https://stackoverflow.com/a/18924454:
# $uiItem = $dte.ToolWindows.SolutionExplorer.GetItem($Solution.Name + '\' + $Project.Name)
# $uiItem.Select([EnvDTE.vsUISelectionType]::vsUISelectionTypeSelect)
# Note2: $dte.ToolWindows may need C# to avoid PropertyNotFoundStrict
$projectNames = ($DTE.Solution.Projects | Select-Object -Expand Name) -join ', '
if (-not $PSCmdlet.ShouldProcess($projectNames -join ', ')) {
return
}
Write-Verbose "Converting $projectNames to Web Application"
# Project.ConverttoWebApplication shows confirm dialog to the user unless:
# - VS is running in command-line mode (VSSPROPID_IsInCommandLineMode)
# - VS is in automation function (VsShellUtilities.IsInAutomationFunction)
# Therefore, tell VS that we are in an automation function
if ($VsExtensibility) { $VsExtensibility.EnterAutomationFunction() }
try {
# Available commands: https://stackoverflow.com/q/13855401
$DTE.ExecuteCommand('Project.ConverttoWebApplication')
} finally {
if ($VsExtensibility) { $VsExtensibility.ExitAutomationFunction() }
}
}
<#
.SYNOPSIS
Runs the Custom Tool for each project item where one is configured.
#>
Function Invoke-CustomTool() {
[CmdletBinding(SupportsShouldProcess)]
Param(
[Parameter(HelpMessage='Project items', Mandatory=$true, Position=0)]
# [EnvDTE.ProjectItems]
$ProjectItems,
[Parameter(HelpMessage='Run even if .designer exists')]
[Switch]
$Force,
[Parameter(HelpMessage='Recurse into sub-projects/items')]
[Switch]
$Recurse
)
foreach ($projectItem in $ProjectItems) {
if ($Recurse) {
if ($projectItem.SubProject) {
Invoke-CustomTool $projectItem.SubProject.ProjectItems `
-Force:$Force -Recurse:$Recurse
}
$projectItems = $projectItem.ProjectItems
if ($null -ne $projectItems -and $projectItems.Count -gt 0) {
Invoke-CustomTool $projectItems -Force:$Force -Recurse:$Recurse
}
}
$properties = $projectItem.Properties
if ($null -ne $properties) {
try {
$customTool = $properties.Item('CustomTool').Value
} catch [ArgumentException] {
# ProjectItem doesn't have CustomTool property (e.g. folders)
$customTool = $null
}
} else {
$customTool = $null
}
$vsProjectItem = $projectItem.Object
if ($customTool `
-and $vsProjectItem -is [VSLangProj.VSProjectItem] `
-and ($Force -or -not (Test-DesignerItem $projectItem))) {
if ($PSCmdlet.ShouldProcess($projectItem.Name)) {
Write-Verbose "Running $customTool on $($projectItem.Name)"
$vsProjectItem.RunCustomTool()
}
} elseif ($customTool) {
Write-Debug "Not running $customTool on $($projectItem.Name)"
} else {
Write-Debug "No CustomTool defined for $($projectItem.Name)"
}
}
}
# TODO: Check if file is already open in VS?
# If assume at most one VS instance, can check Solution/Project of
# $dte = Marshal.GetActiveObject('VisualStudio.DTE')
# To handle multiple VS instances, search Running Object Table
# https://stackoverflow.com/q/13432057
# https://stackoverflow.com/a/10998689
# https://docs.microsoft.com/en-us/visualstudio/extensibility/launch-visual-studio-dte
# Can search for solution path moniker or check Solution/Project of each DTE
# https://docs.microsoft.com/en-us/previous-versions/ms228755(v=vs.140)
# https://stackoverflow.com/q/44342459 (example of ROT moniker enumeration)
$dteType = [Type]::GetTypeFromProgID('VisualStudio.DTE', $true)
$dte = [Activator]::CreateInstance($dteType)
try {
# Fix "Call was Rejected By Callee" errors by registering a message filter
# to retry calls which fail with SERVERCALL_RETRYLATER. See:
# https://docs.microsoft.com/en-us/previous-versions/ms228772(v=vs.140)
[MessageFilter]::Register()
# Check extensibility early, since it may fail due to 64/32-bit differences
try {
$vsExtensibility = [VsExtensibilityWrapper]::FromDTE($DTE)
} catch [InvalidCastException] {
# Some COM classes may not be 64-bit registered (e.g. IVsExtensibility3)
# If REGDB_E_CLASSNOTREG occurs on 64-bit PowerShell, inform the user.
# Note: Decided against immediate error for 64-bit PS in case future
# versions of Visual Studio register necessary classes for 64-bit use.
# Note: Decided against automatic re-launching in 32-bit PowerShell,
# which hides the problem and is unreliable on PowerShell Core
# (Start-Job will fail, and 32-bit version not likely to be installed).
# Note: Could add param to continue w/o $vsExtensibility, if use case.
#
# Expect "(Exception from HRESULT: 0x80040154 (REGDB_E_CLASSNOTREG)"
# in .Message, but .HResult is E_NOINTERFACE. Ugh.
$ex = $_.Exception
if ([IntPtr]::Size -eq 8 `
-and $ex.HResult -in 0x80004002,0x80040154 `
-and $ex.Message.Contains('0x80040154')) {
# Note: Include InnerException message since PS doesn't print it
$userEx = (New-Object InvalidOperationException @(
'Required COM class not available, likely due to 64-bit PowerShell. Consider using PowerShell (x86). ' + $ex,
$ex))
# Note: Construct ErrorRecord so PS doesn't use long Message as
# ErrorID, causing it to be printed twice.
$PSCmdlet.ThrowTerminatingError((New-Object `
System.Management.Automation.ErrorRecord @(
$userEx,
'InvalidOperationLikely6432',
[Management.Automation.ErrorCategory]::InvalidOperation,
$_.TargetObject)))
} else {
$PSCmdlet.ThrowTerminatingError($_)
}
}
if ($itemPath -like '*.sln') {
$dte.Solution.Open($itemPath)
} else {
$dte.Solution.AddFromFile($itemPath, $false)
}
foreach ($project in $dte.Solution.Projects) {
Remove-Designer $project.ProjectItems -Force:$Force -Recurse
Invoke-CustomTool $project.ProjectItems -Force:$Force -Recurse
}
ConvertTo-WebApplication $dte -VsExtensibility $vsExtensibility
foreach ($project in $dte.Solution.Projects) {
Write-Verbose "Saving $($project.Name)"
try {
$project.Save()
} catch [NotImplementedException] {
# Can't save projects without project file (e.g. "Solution Items")
Write-Debug "Unable to save $($project.Name): $_"
}
}
} finally {
$dte.Quit()
[MessageFilter]::Revoke()
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment