Archive

Archive for the ‘PowerShell’ Category

Fix Problem Where Windows PowerShell Cannot Run Script Whose Path Contains Spaces

May 28th, 2013 4 comments

Most people will likely find the “Run script path with spaces from File Explorer” (to be able to double click a PS script whose path contains spaces to run it) section below the most helpful.  Most of the other content in this post can be found elsewhere, but I provide it for context and completeness.

 

Make running (instead of editing) the default PowerShell script action

The default Windows action when you double click on a PowerShell script is to open it in an editor, rather than to actually run the script.  If this bugs you, it’s easy enough to fix.  Just right-click on the script, go to “Open with” –> “Choose default program…”, and then select Windows PowerShell, making sure the “Use this app for all .ps1 files” option is checked (this might be called “Always use the selected program to open this kind of file” or something else depending on which version of Windows you are using).

ChooseDefaultPowerShellApplication     MakeWindowsPowerShellDefaultApplication

If you don’t mind opening in an editor as the default action, then to run the script you can just right-click on the script and choose “Open with” –> “Windows PowerShell”.  This is probably how 90% of people run their PowerShell scripts; power uses might run their scripts directly from the PowerShell command prompt.

 

Error message when trying to run a script whose path contains spaces

So the problem that the 90% of people are likely to encounter is that as soon as the script path has a space in it (either in the filename itself or in the directory path the file resides in), they will see the powershell console flash some red text at them for about 1/10th of a second before it closes, and they will be wondering why the script did not run; or worse, they won’t know that it didn’t run (see the “Keep PowerShell Console Open” section below).  If they are lucky enough to press Print Screen at the right moment, or decide to open up a PowerShell console and run from there, they might see an error message similar to this:

Powershell Invalid Path Error Message

“The term ‘C:\My’ is not recognized as the name of a cmdlet, function, script file, or operable program. Check the spelling of the name, or if a path was included, verify that the path is correct and try again.”

So the path to the script I was trying to run is "C:\My Folder\My PowerShell Script.ps1", but from the error you can see that it cut the path off at the first space.

 

Run script path with spaces from PowerShell console

So the typical work around for this is to open a PowerShell console and run the script by enclosing the path in double quotes.

Windows 8 Pro Tip: You can open the PowerShell console at your current directory in File Explorer by choosing File –> Open Windows PowerShell.

Open Powershell From File Explorer

If you simply try to run the script by enclosing the path to the script in double quotes you will just see the path spit back at you in the console, instead of actually running the script.

Try to run script with spaces the wrong way

The trick is that you have to put “& “ before the script path to actually run the script.  Also, if you are trying to run a script from the current directory without using the full path, you will need to put “.\” before the relative script filename.

Run PowerShell script the right way

 

Run script path with spaces from File Explorer

So when we are in the PowerShell console we can manually type the path enclosed in double quotes, but what do we do when simply trying to run the file from File Explorer (i.e. Windows Explorer in Windows 7 and previous) by double clicking it? 

The answer: Edit the registry to pass the file path to powershell.exe with the path enclosed in quotes.

The problem is that the “HKEY_CLASSES_ROOT\Applications\powershell.exe\shell\open\command” registry key value looks like this:

"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe" "%1"

but we want it to look like this:

"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe" "& \"%1\""

 

So if you want to go manually edit that key by hand you can, or you can simply download the registry script below and then double click the .reg file to have it update the registry key value for you (choose Yes when asked if you want to continue).

Windows Registry Editor Version 5.00

[HKEY_CLASSES_ROOT\Applications\powershell.exe\shell\open\command]
@="\"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe\" \"& \\\"%1\\\"\""

IMHO this seems like a bug with the PowerShell installer (and Windows since PowerShell is built into Windows 7 and up), so please go up-vote the bug I submitted to get this fixed.

So now you can run your PowerShell scripts from File Explorer regardless of whether their path contains spaces or not Smile.  For those interested, this is the post that got me thinking about using the registry to fix this problem.

 

Bonus: Keep PowerShell console open when script is ran from File Explorer

Update – This Bonus section now has its own updated dedicated post here that you should use instead.

When running a script by double-clicking it, if the script completes very quickly the user will see the PowerShell console appear very briefly and then disappear.  If the script gives output that the user wants to see, or if it throws an error, the user won’t have time to read the text.  The typical work around is to open the PowerShell console and manually run the script.  The other option is to adjust our new registry key value a bit.

So to keep the PowerShell console window open after the script completes, we just need to change our new key value to use the –NoExit switch:

"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe" –NoExit "& \"%1\""

And here is the .reg script with the –NoExit switch included:

Windows Registry Editor Version 5.00

[HKEY_CLASSES_ROOT\Applications\powershell.exe\shell\open\command]
@="\"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe\" -NoExit \"& \\\"%1\\\"\""

I hope you find this information as useful as I did.  Happy coding!

Powershell functions to get an xml node, and get and set an xml element’s value, even when the element does not already exist

May 16th, 2013 17 comments

I’m new to working with Xml through PowerShell and was so impressed when I discovered how easy it was to read an xml element’s value.  I’m working with reading/writing .nuspec files for working with NuGet.  Here’s a sample xml of a .nuspec xml file:

<?xml version="1.0" encoding="utf-8"?>
<package xmlns="http://schemas.microsoft.com/packaging/2010/07/nuspec.xsd">
  <metadata>
    <id>MyAppsId</id>
    <version>1.0.1</version>
    <title>MyApp</title>
    <authors>Daniel Schroeder</authors>
    <owners>Daniel Schroeder</owners>
    <requireLicenseAcceptance>false</requireLicenseAcceptance>
    <description>My App.</description>
    <summary>My App.</summary>
    <tags>Powershell, Application</tags>
  </metadata>
  <files>
    <file src="MyApp.ps1" target="content\MyApp.ps1" />
  </files>
</package>

 

In PowerShell if I want to get the version element’s value, I can just do:

# Read in the file contents and return the version node's value.
[ xml ]$fileContents = Get-Content -Path $NuSpecFilePath
return $fileContents.package.metadata.version

 

Wow, that’s super easy.  And if I want to update that version number, I can just do:

# Read in the file contents, update the version node's value, and save the file.
[ xml ] $fileContents = Get-Content -Path $NuSpecFilePath
$fileContents.package.metadata.version = $NewVersionNumber
$fileContents.Save($NuSpecFilePath)

 

Holy smokes. So simple it blows my mind.  So everything is great, right?  Well, it is until you try and read or write to an element that doesn’t exist.  If the <version> element is not in the xml, when I try and read from it or write to it, I get an error such as “Error: Property ‘version’ cannot be found on this object. Make sure that it exists.”.  You would think that checking if an element exists would be straight-forward and easy right? Well, it almost is.  There’s a SelectSingleNode() function that we can use to look for the element, but what I realized after a couple hours of banging my head on the wall and stumbling across this stack overflow post, is that in order for this function to work properly, you really need to use the overloaded method that also takes an XmlNamespaceManager; otherwise the SelectSingleNode() function always returns null.

So basically you need an extra 2 lines in order to setup an XmlNamespaceManager every time you need to look for a node.  This is a little painful, so instead I created this function that will get you the node if it exists, and return $null if it doesn’t:

function Get-XmlNode([ xml ]$XmlDocument, [string]$NodePath, [string]$NamespaceURI = "", [string]$NodeSeparatorCharacter = '.')
{
	# If a Namespace URI was not given, use the Xml document's default namespace.
	if ([string]::IsNullOrEmpty($NamespaceURI)) { $NamespaceURI = $XmlDocument.DocumentElement.NamespaceURI }	
	
	# In order for SelectSingleNode() to actually work, we need to use the fully qualified node path along with an Xml Namespace Manager, so set them up.
	$xmlNsManager = New-Object System.Xml.XmlNamespaceManager($XmlDocument.NameTable)
	$xmlNsManager.AddNamespace("ns", $NamespaceURI)
	$fullyQualifiedNodePath = "/ns:$($NodePath.Replace($($NodeSeparatorCharacter), '/ns:'))"
	
	# Try and get the node, then return it. Returns $null if the node was not found.
	$node = $XmlDocument.SelectSingleNode($fullyQualifiedNodePath, $xmlNsManager)
	return $node
}

 

And you would call this function like so:

# Read in the file contents and return the version node's value.
[ xml ]$fileContents = Get-Content -Path $NuSpecFilePath
$node = Get-XmlNode -XmlDocument $fileContents -NodePath "package.metadata.version"
if ($node -eq $null) { return $null }
return $fileContents.package.metadata.version

 

So if the node doesn’t exist (i.e. is $null), I return $null instead of trying to access the non-existent element.

So by default this Get-XmlNode function uses the xml’s root namespace, which is what we want 95% of the time.  It also takes a NodeSeparatorCharacter that defaults to a period.  While Googling for answers I saw that many people use the the syntax “$fileContents/package/metadata/version” instead of “$fileContents.package.metadata.version”.  I prefer the dot notation, but for those who like the slash just override the NodeSeparatorCharacter with a slash.

<Update>

Later I found that I also wanted the ability to return back multiple xml nodes; that is, if multiple “version” elements were defined I wanted to get them all, not just the first one.  This is simple; instead of using .SelectSingleNode() we can use .SelectNodes().  In order to avoid duplicating code, I broke the code to get the Xml Namespace Manager and Fully Qualified Node Path out into their own functions.  Here is the rewritten code, with the new Get-XmlNodes function:

function Get-XmlNamespaceManager([ xml ]$XmlDocument, [string]$NamespaceURI = "")
{
    # If a Namespace URI was not given, use the Xml document's default namespace.
	if ([string]::IsNullOrEmpty($NamespaceURI)) { $NamespaceURI = $XmlDocument.DocumentElement.NamespaceURI }	
	
	# In order for SelectSingleNode() to actually work, we need to use the fully qualified node path along with an Xml Namespace Manager, so set them up.
	[System.Xml.XmlNamespaceManager]$xmlNsManager = New-Object System.Xml.XmlNamespaceManager($XmlDocument.NameTable)
	$xmlNsManager.AddNamespace("ns", $NamespaceURI)
    return ,$xmlNsManager		# Need to put the comma before the variable name so that PowerShell doesn't convert it into an Object[].
}

function Get-FullyQualifiedXmlNodePath([string]$NodePath, [string]$NodeSeparatorCharacter = '.')
{
    return "/ns:$($NodePath.Replace($($NodeSeparatorCharacter), '/ns:'))"
}

function Get-XmlNode([ xml ]$XmlDocument, [string]$NodePath, [string]$NamespaceURI = "", [string]$NodeSeparatorCharacter = '.')
{
	$xmlNsManager = Get-XmlNamespaceManager -XmlDocument $XmlDocument -NamespaceURI $NamespaceURI
	[string]$fullyQualifiedNodePath = Get-FullyQualifiedXmlNodePath -NodePath $NodePath -NodeSeparatorCharacter $NodeSeparatorCharacter
	
	# Try and get the node, then return it. Returns $null if the node was not found.
	$node = $XmlDocument.SelectSingleNode($fullyQualifiedNodePath, $xmlNsManager)
	return $node
}

function Get-XmlNodes([ xml ]$XmlDocument, [string]$NodePath, [string]$NamespaceURI = "", [string]$NodeSeparatorCharacter = '.')
{
	$xmlNsManager = Get-XmlNamespaceManager -XmlDocument $XmlDocument -NamespaceURI $NamespaceURI
	[string]$fullyQualifiedNodePath = Get-FullyQualifiedXmlNodePath -NodePath $NodePath -NodeSeparatorCharacter $NodeSeparatorCharacter

	# Try and get the nodes, then return them. Returns $null if no nodes were found.
	$nodes = $XmlDocument.SelectNodes($fullyQualifiedNodePath, $xmlNsManager)
	return $nodes
}

Note the comma in the return statement of the Get-XmlNamespaceManager function.  It took me a while to discover why things broke without it.

</Update>

So once I had this, I decided that I might as well make functions for easily getting and setting the text values of an xml element, which is what is provided here:

function Get-XmlElementsTextValue([ xml ]$XmlDocument, [string]$ElementPath, [string]$NamespaceURI = "", [string]$NodeSeparatorCharacter = '.')
{
	# Try and get the node.	
	$node = Get-XmlNode -XmlDocument $XmlDocument -NodePath $ElementPath -NamespaceURI $NamespaceURI -NodeSeparatorCharacter $NodeSeparatorCharacter
	
	# If the node already exists, return its value, otherwise return null.
	if ($node) { return $node.InnerText } else { return $null }
}

function Set-XmlElementsTextValue([ xml ]$XmlDocument, [string]$ElementPath, [string]$TextValue, [string]$NamespaceURI = "", [string]$NodeSeparatorCharacter = '.')
{
	# Try and get the node.	
	$node = Get-XmlNode -XmlDocument $XmlDocument -NodePath $ElementPath -NamespaceURI $NamespaceURI -NodeSeparatorCharacter $NodeSeparatorCharacter
	
	# If the node already exists, update its value.
	if ($node)
	{ 
		$node.InnerText = $TextValue
	}
	# Else the node doesn't exist yet, so create it with the given value.
	else
	{
		# Create the new element with the given value.
		$elementName = $ElementPath.SubString($ElementPath.LastIndexOf($NodeSeparatorCharacter) + 1)
 		$element = $XmlDocument.CreateElement($elementName, $XmlDocument.DocumentElement.NamespaceURI)		
		$textNode = $XmlDocument.CreateTextNode($TextValue)
		$element.AppendChild($textNode) > $null
		
		# Try and get the parent node.
		$parentNodePath = $ElementPath.SubString(0, $ElementPath.LastIndexOf($NodeSeparatorCharacter))
		$parentNode = Get-XmlNode -XmlDocument $XmlDocument -NodePath $parentNodePath -NamespaceURI $NamespaceURI -NodeSeparatorCharacter $NodeSeparatorCharacter
		
		if ($parentNode)
		{
			$parentNode.AppendChild($element) > $null
		}
		else
		{
			throw "$parentNodePath does not exist in the xml."
		}
	}
}

 

The Get-XmlElementsTextValue function is pretty straight forward; return the value if it exists, otherwise return null.  The Set-XmlElementsTextValue is a little more involved because if the element does not exist already, we need to create the new element and attach it as a child to the parent element.

Here’s an example of calling Get-XmlElementsTextValue:

# Read in the file contents and return the version element's value.
[ xml ]$fileContents = Get-Content -Path $NuSpecFilePath
return Get-XmlElementsTextValue -XmlDocument $fileContents -ElementPath "package.metadata.version"

 

And an example of calling Set-XmlElementsTextValue:

# Read in the file contents, update the version element's value, and save the file.
[ xml ]$fileContents = Get-Content -Path $NuSpecFilePath
Set-XmlElementsTextValue -XmlDocument $fileContents -ElementPath "package.metadata.version" -TextValue $NewVersionNumber
$fileContents.Save($NuSpecFilePath)

 

Note that these 2 functions depend on the Get-XmlNode function provided above.

<Update2 – January 7, 2016>

I have had multiple people ask me for similar functions for getting and setting an element’s Attribute value as well, so here are the corresponding functions for that:

function Get-XmlElementsAttributeValue([ xml ]$XmlDocument, [string]$ElementPath, [string]$AttributeName, [string]$NamespaceURI = "", [string]$NodeSeparatorCharacter = '.')
{
	# Try and get the node. 
	$node = Get-XmlNode -XmlDocument $XmlDocument -NodePath $ElementPath -NamespaceURI $NamespaceURI -NodeSeparatorCharacter $NodeSeparatorCharacter
	
	# If the node and attribute already exist, return the attribute's value, otherwise return null.
	if ($node -and $node.$AttributeName) { return $node.$AttributeName } else { return $null }
}

function Set-XmlElementsAttributeValue([ xml ]$XmlDocument, [string]$ElementPath, [string]$AttributeName, [string]$AttributeValue, [string]$NamespaceURI = "", [string]$NodeSeparatorCharacter = '.')
{
	# Try and get the node. 
	$node = Get-XmlNode -XmlDocument $XmlDocument -NodePath $ElementPath -NamespaceURI $NamespaceURI -NodeSeparatorCharacter $NodeSeparatorCharacter
	
	# If the node already exists, create/update its attribute's value.
	if ($node)
	{ 
		$attribute = $XmlDocument.CreateNode([System.Xml.XmlNodeType]::Attribute, $AttributeName, $NamespaceURI)
		$attribute.Value = $AttributeValue
		$node.Attributes.SetNamedItem($attribute) > $null
	}
	# Else the node doesn't exist yet, so create it with the given attribute value.
	else
	{
		# Create the new element with the given value.
		$elementName = $ElementPath.SubString($ElementPath.LastIndexOf($NodeSeparatorCharacter) + 1)
		$element = $XmlDocument.CreateElement($elementName, $XmlDocument.DocumentElement.NamespaceURI)
		$element.SetAttribute($AttributeName, $NamespaceURI, $AttributeValue) > $null
		
		# Try and get the parent node.
		$parentNodePath = $ElementPath.SubString(0, $ElementPath.LastIndexOf($NodeSeparatorCharacter))
		$parentNode = Get-XmlNode -XmlDocument $XmlDocument -NodePath $parentNodePath -NamespaceURI $NamespaceURI -NodeSeparatorCharacter $NodeSeparatorCharacter
		
		if ($parentNode)
		{
			$parentNode.AppendChild($element) > $null
		}
		else
		{
			throw "$parentNodePath does not exist in the xml."
		}
	}
}

</Update2>

Rather than copy-pasting, you can download all of the functions shown here.

I hope you find this useful and that it saves you some time.  Happy coding!

PowerShell function to create a password protected zip file

May 9th, 2013 3 comments

There are a few different ways to create zip files in powershell, but not many that allow you to create one that is password protected.  I found this post that shows how to do it using 7zip, so I thought I would share my modified solution.

Here is the function I wrote that uses 7zip to perform the zip, since 7zip supports using a password to zip the files.  This script looks for the 7zip executable (7z.exe) in the default install locations, and if not found it will use the stand-alone 7zip executable (7za.exe) if it is in the same directory as the powershell script.

Updated function to support multiple compression types: 7z, zip, gzip, bzip2, tar, iso, and udf.

function Write-ZipUsing7Zip([string]$FilesToZip, [string]$ZipOutputFilePath, [string]$Password, [ValidateSet('7z','zip','gzip','bzip2','tar','iso','udf')][string]$CompressionType = 'zip', [switch]$HideWindow)
{
	# Look for the 7zip executable.
	$pathTo32Bit7Zip = "C:\Program Files (x86)\7-Zip\7z.exe"
	$pathTo64Bit7Zip = "C:\Program Files\7-Zip\7z.exe"
	$THIS_SCRIPTS_DIRECTORY = Split-Path $script:MyInvocation.MyCommand.Path
	$pathToStandAloneExe = Join-Path $THIS_SCRIPTS_DIRECTORY "7za.exe"
	if (Test-Path $pathTo64Bit7Zip) { $pathTo7ZipExe = $pathTo64Bit7Zip } 
	elseif (Test-Path $pathTo32Bit7Zip) { $pathTo7ZipExe = $pathTo32Bit7Zip }
	elseif (Test-Path $pathToStandAloneExe) { $pathTo7ZipExe = $pathToStandAloneExe }
	else { throw "Could not find the 7-zip executable." }
	
	# Delete the destination zip file if it already exists (i.e. overwrite it).
	if (Test-Path $ZipOutputFilePath) { Remove-Item $ZipOutputFilePath -Force }
	
	$windowStyle = "Normal"
	if ($HideWindow) { $windowStyle = "Hidden" }
	
	# Create the arguments to use to zip up the files.
	# Command-line argument syntax can be found at: http://www.dotnetperls.com/7-zip-examples
	$arguments = "a -t$CompressionType ""$ZipOutputFilePath"" ""$FilesToZip"" -mx9"
	if (!([string]::IsNullOrEmpty($Password))) { $arguments += " -p$Password" }
	
	# Zip up the files.
	$p = Start-Process $pathTo7ZipExe -ArgumentList $arguments -Wait -PassThru -WindowStyle $windowStyle

	# If the files were not zipped successfully.
	if (!(($p.HasExited -eq $true) -and ($p.ExitCode -eq 0))) 
	{
		throw "There was a problem creating the zip file '$ZipFilePath'."
	}
}

And here’s some examples of how to call the function:

Write-ZipUsing7Zip -FilesToZip "C:\SomeFolder" -ZipOutputFilePath "C:\SomeFolder.zip" -Password "password123"
Write-ZipUsing7Zip "C:\Folder\*.txt" "C:\FoldersTxtFiles.zip" -HideWindow

 

I hope you find this useful.

Happy coding!

PowerShell Multi-Line Input Box Dialog, Open File Dialog, Folder Browser Dialog, Input Box, and Message Box

May 1st, 2013 39 comments

Updated May 17, 2013 to fix potential bug and add more parameters to some functions.

Updated Dec 5, 2013 to release COM object from Read-FolderBrowserDialog function.

I love PowerShell, and when prompting users for input I often prefer to use GUI controls rather than have them enter everything into the console, as some things like browsing for files or folders or entering multi-line text aren’t very pleasing to do directly in the PowerShell prompt window.  So I thought I’d share some PowerShell code that I often use for these purposes.  Below I give the code for creating each type of GUI control from a function, an example of calling the function, and a screen shot of what the resulting GUI control looks like.

Show a message box

Function:

# Show message box popup and return the button clicked by the user.
function Read-MessageBoxDialog([string]$Message, [string]$WindowTitle, [System.Windows.Forms.MessageBoxButtons]$Buttons = [System.Windows.Forms.MessageBoxButtons]::OK, [System.Windows.Forms.MessageBoxIcon]$Icon = [System.Windows.Forms.MessageBoxIcon]::None)
{
	Add-Type -AssemblyName System.Windows.Forms
	return [System.Windows.Forms.MessageBox]::Show($Message, $WindowTitle, $Buttons, $Icon)
}

Example:

$buttonClicked = Read-MessageBoxDialog -Message "Please press the OK button." -WindowTitle "Message Box Example" -Buttons OKCancel -Icon Exclamation
if ($buttonClicked -eq "OK") { Write-Host "Thanks for pressing OK" }
else { Write-Host "You clicked $buttonClicked" }

Message Box Example

 

Prompt for single-line user input

Function:

# Show input box popup and return the value entered by the user.
function Read-InputBoxDialog([string]$Message, [string]$WindowTitle, [string]$DefaultText)
{
	Add-Type -AssemblyName Microsoft.VisualBasic
	return [Microsoft.VisualBasic.Interaction]::InputBox($Message, $WindowTitle, $DefaultText)
}

Example:

$textEntered = Read-InputBoxDialog -Message "Please enter the word 'Banana'" -WindowTitle "Input Box Example" -DefaultText "Apple"
if ($textEntered -eq $null) { Write-Host "You clicked Cancel" }
elseif ($textEntered -eq "Banana") { Write-Host "Thanks for typing Banana" }
else { Write-Host "You entered $textEntered" }

Input Box Example

 

Prompt for a file (based on a post the Scripting Guy made)

Function:

# Show an Open File Dialog and return the file selected by the user.
function Read-OpenFileDialog([string]$WindowTitle, [string]$InitialDirectory, [string]$Filter = "All files (*.*)|*.*", [switch]$AllowMultiSelect)
{  
	Add-Type -AssemblyName System.Windows.Forms
	$openFileDialog = New-Object System.Windows.Forms.OpenFileDialog
	$openFileDialog.Title = $WindowTitle
	if (![string]::IsNullOrWhiteSpace($InitialDirectory)) { $openFileDialog.InitialDirectory = $InitialDirectory }
	$openFileDialog.Filter = $Filter
	if ($AllowMultiSelect) { $openFileDialog.MultiSelect = $true }
	$openFileDialog.ShowHelp = $true	# Without this line the ShowDialog() function may hang depending on system configuration and running from console vs. ISE.
	$openFileDialog.ShowDialog() > $null
	if ($AllowMultiSelect) { return $openFileDialog.Filenames } else { return $openFileDialog.Filename }
}

Example:

$filePath = Read-OpenFileDialog -WindowTitle "Select Text File Example" -InitialDirectory 'C:\' -Filter "Text files (*.txt)|*.txt"
if (![string]::IsNullOrEmpty($filePath)) { Write-Host "You selected the file: $filePath" }
else { "You did not select a file." }

Select Text File Example

 

Prompt for a directory (based on this post, as using System.Windows.Forms.FolderBrowserDialog may hang depending on system configuration and running from the console vs. PS ISE)

Function:

# Show an Open Folder Dialog and return the directory selected by the user.
function Read-FolderBrowserDialog([string]$Message, [string]$InitialDirectory, [switch]$NoNewFolderButton)
{
    $browseForFolderOptions = 0
    if ($NoNewFolderButton) { $browseForFolderOptions += 512 }

	$app = New-Object -ComObject Shell.Application
	$folder = $app.BrowseForFolder(0, $Message, $browseForFolderOptions, $InitialDirectory)
	if ($folder) { $selectedDirectory = $folder.Self.Path } else { $selectedDirectory = '' }
	[System.Runtime.Interopservices.Marshal]::ReleaseComObject($app) > $null
	return $selectedDirectory
}

Example:

$directoryPath = Read-FolderBrowserDialog -Message "Please select a directory" -InitialDirectory 'C:\' -NoNewFolderButton
if (![string]::IsNullOrEmpty($directoryPath)) { Write-Host "You selected the directory: $directoryPath" }
else { "You did not select a directory." }

Browse For Folder

 

Prompt for multi-line user input (based on code shown in this TechNet article)

Function:

function Read-MultiLineInputBoxDialog([string]$Message, [string]$WindowTitle, [string]$DefaultText)
{
<#
	.SYNOPSIS
	Prompts the user with a multi-line input box and returns the text they enter, or null if they cancelled the prompt.
	
	.DESCRIPTION
	Prompts the user with a multi-line input box and returns the text they enter, or null if they cancelled the prompt.
	
	.PARAMETER Message
	The message to display to the user explaining what text we are asking them to enter.
	
	.PARAMETER WindowTitle
	The text to display on the prompt window's title.
	
	.PARAMETER DefaultText
	The default text to show in the input box.
	
	.EXAMPLE
	$userText = Read-MultiLineInputDialog "Input some text please:" "Get User's Input"
	
	Shows how to create a simple prompt to get mutli-line input from a user.
	
	.EXAMPLE
	# Setup the default multi-line address to fill the input box with.
	$defaultAddress = @'
	John Doe
	123 St.
	Some Town, SK, Canada
	A1B 2C3
	'@
	
	$address = Read-MultiLineInputDialog "Please enter your full address, including name, street, city, and postal code:" "Get User's Address" $defaultAddress
	if ($address -eq $null)
	{
		Write-Error "You pressed the Cancel button on the multi-line input box."
	}
	
	Prompts the user for their address and stores it in a variable, pre-filling the input box with a default multi-line address.
	If the user pressed the Cancel button an error is written to the console.
	
	.EXAMPLE
	$inputText = Read-MultiLineInputDialog -Message "If you have a really long message you can break it apart`nover two lines with the powershell newline character:" -WindowTitle "Window Title" -DefaultText "Default text for the input box."
	
	Shows how to break the second parameter (Message) up onto two lines using the powershell newline character (`n).
	If you break the message up into more than two lines the extra lines will be hidden behind or show ontop of the TextBox.
	
	.NOTES
	Name: Show-MultiLineInputDialog
	Author: Daniel Schroeder (originally based on the code shown at http://technet.microsoft.com/en-us/library/ff730941.aspx)
	Version: 1.0
#>
	Add-Type -AssemblyName System.Drawing
	Add-Type -AssemblyName System.Windows.Forms
	
	# Create the Label.
	$label = New-Object System.Windows.Forms.Label
	$label.Location = New-Object System.Drawing.Size(10,10) 
	$label.Size = New-Object System.Drawing.Size(280,20)
	$label.AutoSize = $true
	$label.Text = $Message
	
	# Create the TextBox used to capture the user's text.
	$textBox = New-Object System.Windows.Forms.TextBox 
	$textBox.Location = New-Object System.Drawing.Size(10,40) 
	$textBox.Size = New-Object System.Drawing.Size(575,200)
	$textBox.AcceptsReturn = $true
	$textBox.AcceptsTab = $false
	$textBox.Multiline = $true
	$textBox.ScrollBars = 'Both'
	$textBox.Text = $DefaultText
	
	# Create the OK button.
	$okButton = New-Object System.Windows.Forms.Button
	$okButton.Location = New-Object System.Drawing.Size(415,250)
	$okButton.Size = New-Object System.Drawing.Size(75,25)
	$okButton.Text = "OK"
	$okButton.Add_Click({ $form.Tag = $textBox.Text; $form.Close() })
	
	# Create the Cancel button.
	$cancelButton = New-Object System.Windows.Forms.Button
	$cancelButton.Location = New-Object System.Drawing.Size(510,250)
	$cancelButton.Size = New-Object System.Drawing.Size(75,25)
	$cancelButton.Text = "Cancel"
	$cancelButton.Add_Click({ $form.Tag = $null; $form.Close() })
	
	# Create the form.
	$form = New-Object System.Windows.Forms.Form 
	$form.Text = $WindowTitle
	$form.Size = New-Object System.Drawing.Size(610,320)
	$form.FormBorderStyle = 'FixedSingle'
	$form.StartPosition = "CenterScreen"
	$form.AutoSizeMode = 'GrowAndShrink'
	$form.Topmost = $True
	$form.AcceptButton = $okButton
	$form.CancelButton = $cancelButton
	$form.ShowInTaskbar = $true
	
	# Add all of the controls to the form.
	$form.Controls.Add($label)
	$form.Controls.Add($textBox)
	$form.Controls.Add($okButton)
	$form.Controls.Add($cancelButton)
	
	# Initialize and show the form.
	$form.Add_Shown({$form.Activate()})
	$form.ShowDialog() > $null	# Trash the text of the button that was clicked.
	
	# Return the text that the user entered.
	return $form.Tag
}

Example:

$multiLineText = Read-MultiLineInputBoxDialog -Message "Please enter some text. It can be multiple lines" -WindowTitle "Multi Line Example" -DefaultText "Enter some text here..."
if ($multiLineText -eq $null) { Write-Host "You clicked Cancel" }
else { Write-Host "You entered the following text: $multiLineText" }

Multi Line Example

 

All of these but the multi-line input box just use existing Windows Forms / Visual Basic controls.

I originally was using the Get verb to prefix the functions, then switched to the Show verb, but after reading through this page, I decided that the Read verb is probably the most appropriate (and it lines up with the Read-Host cmdlet).

Hopefully you find this useful.

Happy coding!

Invoke-MsBuild Powershell Module

April 5th, 2013 2 comments

Update: I’ve moved this project to it’s own new home at https://invokemsbuild.codeplex.com.  All updates will be made there.

I’ve spent a little while creating a powershell module that can be used to call MsBuild.  It returns whether the build succeeded or not, and runs through the Visual Studio command prompt if possible, since some projects can’t be built by calling msbuild directly (e.g. XNA projects).  It also provides several other parameters to do things like show the window performing the build, automatically open the build log if the build fails, etc.

Here is the script (copy-paste the code into a file called Invoke-MsBuild.psm1 go download the updated version):

function Invoke-MsBuild
{
<#
    .SYNOPSIS
    Builds the given Visual Studio solution or project file using MSBuild.
     
    .DESCRIPTION
    Executes the MSBuild.exe tool against the specified Visual Studio solution or project file.
    Returns true if the build succeeded, false if the build failed.
    If using the PathThru switch, the process running MSBuild is returned instead.
     
    .PARAMETER Path
    The path of the Visual Studio solution or project to build (e.g. a .sln or .csproj file).
     
    .PARAMETER MsBuildParameters
    Additional parameters to pass to the MsBuild command-line tool. This can be any valid MsBuild command-line parameters except for the path of
    the solution/project to build.
 
http://msdn.microsoft.com/en-ca/library/vstudio/ms164311.aspx
 
    .PARAMETER $BuildLogDirectoryPath
    The directory path to write the build log file to.
    Defaults to putting the log file in the users temp directory (e.g. C:\Users\[User Name]\AppData\Local\Temp).
    Use the keyword "PathDirectory" to put the log file in the same directory as the .sln or project file being built.
     
    .PARAMETER AutoLaunchBuildLog
    If set, this switch will cause the build log to automatically be launched into the default viewer if the build fails.
    NOTE: This switch cannot be used with the PassThru switch.
     
    .PARAMETER KeepBuildLogOnSuccessfulBuilds
    If set, this switch will cause the msbuild log file to not be deleted on successful builds; normally it is only kept around on failed builds.
    NOTE: This switch cannot be used with the PassThru switch.
     
    .PARAMETER ShowBuildWindow
    If set, this switch will cause a command prompt window to be shown in order to view the progress of the build.
     
    .PARAMETER ShowBuildWindowAndPromptForInputBeforeClosing
    If set, this switch will cause a command prompt window to be shown in order to view the progress of the build, and it will remain open
    after the build completes until the user presses a key on it.
    NOTE: If not using PassThru, the user will need to provide input before execution will return back to the calling script.
     
    .PARAMETER PassThru
    If set, this switch will cause the script not to wait until the build (launched in another process) completes before continuing execution.
    Instead the build will be started in a new process and that process will immediately be returned, allowing the calling script to continue
    execution while the build is performed, and also to inspect the process to see when it completes.
    NOTE: This switch cannot be used with the AutoLaunchBuildLog or KeepBuildLogOnSuccessfulBuilds switches.
     
    .PARAMETER GetLogPath
    If set, the build will not actually be performed.
    Instead it will just return the full path of the MsBuild Log file that would be created if the build is performed with the same parameters.
     
    .EXAMPLE
    Invoke-MsBuild -Path "C:\Some Folder\MySolution.sln"
     
    Perform the default MSBuild actions on the Visual Studio solution to build the projects in it.
    The PowerShell script will halt execution until MsBuild completes.
     
    .EXAMPLE
    Invoke-MsBuild -Path "C:\Some Folder\MySolution.sln" -PassThru
     
    Perform the default MSBuild actions on the Visual Studio solution to build the projects in it.
    The PowerShell script will not halt execution; instead it will return the process performing MSBuild actions back to the caller while the action is performed.
     
    .EXAMPLE
    Invoke-MsBuild -Path "C:\Some Folder\MyProject.csproj" -MsBuildParameters "/target:Clean;Build" -ShowBuildWindow
     
    Cleans then Builds the given C# project.
    A window displaying the output from MsBuild will be shown so the user can view the progress of the build.
     
    .EXAMPLE
    Invoke-MsBuild -Path "C:\MySolution.sln" -Params "/target:Clean;Build /property:Configuration=Release;Platform=x64;BuildInParallel=true /verbosity:Detailed /maxcpucount"
     
    Cleans then Builds the given solution, specifying to build the project in parallel in the Release configuration for the x64 platform.
    Here the shorter "Params" alias is used instead of the full "MsBuildParameters" parameter name.
     
    .EXAMPLE
    Invoke-MsBuild -Path "C:\Some Folder\MyProject.csproj" -ShowBuildWindowAndPromptForInputBeforeClosing -AutoLaunchBuildLog
     
    Builds the given C# project.
    A window displaying the output from MsBuild will be shown so the user can view the progress of the build, and it will not close until the user
    gives the window some input. This function will also not return until the user gives the window some input, halting the powershell script execution.
    If the build fails, the build log will automatically be opened in the default text viewer.
     
    .EXAMPLE
    Invoke-MsBuild -Path "C:\Some Folder\MyProject.csproj" -BuildLogDirectoryPath "C:\BuildLogs" -KeepBuildLogOnSuccessfulBuilds -AutoLaunchBuildLog
     
    Builds the given C# project.
    The build log will be saved in "C:\BuildLogs", and they will not be automatically deleted even if the build succeeds.
    If the build fails, the build log will automatically be opened in the default text viewer.
     
    .EXAMPLE
    Invoke-MsBuild -Path "C:\Some Folder\MyProject.csproj" -BuildLogDirectoryPath PathDirectory
     
    Builds the given C# project.
    The build log will be saved in "C:\Some Folder\", which is the same directory as the project being built (i.e. directory specified in the Path).
     
    .EXAMPLE
    Invoke-MsBuild -Path "C:\Database\Database.dbproj" -P "/t:Deploy /p:TargetDatabase=MyDatabase /p:TargetConnectionString=`"Data Source=DatabaseServerName`;Integrated Security=True`;Pooling=False`" /p:DeployToDatabase=True"
     
    Deploy the Visual Studio Database Project to the database "MyDatabase".
    Here the shorter "P" alias is used instead of the full "MsBuildParameters" parameter name.
    The shorter alias' of the msbuild parameters are also used; "/t" instead of "/target", and "/p" instead of "/property".
     
    .EXAMPLE
    Invoke-MsBuild -Path "C:\Some Folder\MyProject.csproj" -BuildLogDirectoryPath "C:\BuildLogs" -GetLogPath
     
    Returns the full path to the MsBuild Log file that would be created if the build was ran with the same parameters.
    In this example the returned log path might be "C:\BuildLogs\MyProject.msbuild.log".
    If the BuildLogDirectoryPath was not provided, the returned log path might be "C:\Some Folder\MyProject.msbuild.log".
     
    .NOTES
    Name:   Invoke-MsBuild
    Author: Daniel Schroeder (originally based on the module at http://geekswithblogs.net/dwdii/archive/2011/05/27/part-2-automating-a-visual-studio-build-with-powershell.aspx)
    Version: 1.1
#>
    [CmdletBinding(DefaultParameterSetName="Wait")]
    param
    (
        [parameter(Position=0,Mandatory=$true,ValueFromPipeline=$true,HelpMessage="The path to the file to build with MsBuild (e.g. a .sln or .csproj file).")]
        [ValidateScript({Test-Path $_})]
        [string] $Path,
 
        [parameter(Mandatory=$false)]
        [Alias("Params")]
        [Alias("P")]
        [string] $MsBuildParameters,
 
        [parameter(Mandatory=$false)]
        [ValidateNotNullOrEmpty()]
        [Alias("L")]
        [string] $BuildLogDirectoryPath = $env:Temp,
 
        [parameter(Mandatory=$false,ParameterSetName="Wait")]
        [ValidateNotNullOrEmpty()]
        [Alias("AutoLaunch")]
        [Alias("A")]
        [switch] $AutoLaunchBuildLogOnFailure,
 
        [parameter(Mandatory=$false,ParameterSetName="Wait")]
        [ValidateNotNullOrEmpty()]
        [Alias("Keep")]
        [Alias("K")]
        [switch] $KeepBuildLogOnSuccessfulBuilds,
 
        [parameter(Mandatory=$false)]
        [Alias("Show")]
        [Alias("S")]
        [switch] $ShowBuildWindow,
 
        [parameter(Mandatory=$false)]
        [Alias("Prompt")]
        [switch] $ShowBuildWindowAndPromptForInputBeforeClosing,
 
        [parameter(Mandatory=$false,ParameterSetName="PassThru")]
        [switch] $PassThru,
         
        [parameter(Mandatory=$false)]
        [Alias("Get")]
        [Alias("G")]
        [switch] $GetLogPath
    )
 
    BEGIN { }
    END { }
    PROCESS
    {
        # Turn on Strict Mode to help catch syntax-related errors.
        #   This must come after a script's/function's param section.
        #   Forces a function to be the first non-comment code to appear in a PowerShell Script/Module.
        Set-StrictMode -Version Latest
 
        # If the keyword was supplied, place the log in the same folder as the solution/project being built.
        if ($BuildLogDirectoryPath.Equals("PathDirectory", [System.StringComparison]::InvariantCultureIgnoreCase))
        {
            $BuildLogDirectoryPath = [System.IO.Path]::GetDirectoryName($Path)
        }
 
        # Store the VS Command Prompt to do the build in, if one exists.
        $vsCommandPrompt = Get-VisualStudioCommandPromptPath
 
        # Local Variables.
        $solutionFileName = (Get-ItemProperty -Path $Path).Name
        $buildLogFilePath = (Join-Path $BuildLogDirectoryPath $solutionFileName) + ".msbuild.log"
        $windowStyle = if ($ShowBuildWindow -or $ShowBuildWindowAndPromptForInputBeforeClosing) { "Normal" } else { "Hidden" }
        $buildCrashed = $false;
     
        # If all we want is the path to the Log file that will be generated, return it.
        if ($GetLogPath)
        {
            return $buildLogFilePath
        }
 
        # Try and build the solution.
        try
        {
            # Build the arguments to pass to MsBuild.
            $buildArguments = """$Path"" $MsBuildParameters /fileLoggerParameters:LogFile=""$buildLogFilePath"""
 
            # If a VS Command Prompt was found, call MSBuild from that since it sets environmental variables that may be needed to build some projects.
            if ($vsCommandPrompt -ne $null)
            {
                $cmdArgumentsToRunMsBuild = "/k "" ""$vsCommandPrompt"" & msbuild "
            }
            # Else the VS Command Prompt was not found, so just build using MSBuild directly.
            else
            {
                # Get the path to the MsBuild executable.
                $msBuildPath = Get-MsBuildPath
                $cmdArgumentsToRunMsBuild = "/k "" ""$msBuildPath"" "
            }
             
            # Append the MSBuild arguments to pass into cmd.exe in order to do the build.
            $pauseForInput = if ($ShowBuildWindowAndPromptForInputBeforeClosing) { "Pause & " } else { "" }
            $cmdArgumentsToRunMsBuild += "$buildArguments & $pauseForInput Exit"" "
 
            Write-Debug "Starting new cmd.exe process with arguments ""$cmdArgumentsToRunMsBuild""."
 
            # Perform the build.
            if ($PassThru)
            {
                return Start-Process cmd.exe -ArgumentList $cmdArgumentsToRunMsBuild -WindowStyle $windowStyle -PassThru
            }
            else
            {
                Start-Process cmd.exe -ArgumentList $cmdArgumentsToRunMsBuild -WindowStyle $windowStyle -Wait
            }
        }
        catch
        {
            $buildCrashed = $true;
            $errorMessage = $_
            Write-Error ("Unexpect error occured while building ""$Path"": $errorMessage" );
        }
 
        # If the build crashed, return that the build didn't succeed.
        if ($buildCrashed)
        {
            return $false
        }
     
        # Get if the build failed or not by looking at the log file.
        $buildSucceeded = ((Select-String -Path $buildLogFilePath -Pattern "Build FAILED." -SimpleMatch) -eq $null)
 
        # If the build succeeded.
        if ($buildSucceeded)
        {
            # If we shouldn't keep the log around, delete it.
            if (!$KeepBuildLogOnSuccessfulBuilds)
            {
                Remove-Item -Path $buildLogFilePath -Force
            }
        }
        # Else at least one of the projects failed to build.
        else
        {
            # Write the error message as a warning.
            Write-Warning "FAILED to build ""$Path"". Please check the build log ""$buildLogFilePath"" for details."
 
            # If we should show the build log automatically, open it with the default viewer.
            if($AutoLaunchBuildLogOnFailure)
            {
                Start-Process -verb "Open" $buildLogFilePath;
            }
        }
     
        # Return if the Build Succeeded or Failed.
        return $buildSucceeded
    }
}
 
function Get-VisualStudioCommandPromptPath
{
 <#
    .SYNOPSIS
        Gets the file path to the latest Visual Studio Command Prompt. Returns $null if a path is not found.
     
    .DESCRIPTION
        Gets the file path to the latest Visual Studio Command Prompt. Returns $null if a path is not found.
    #>
 
# Get some environmental paths.
$vs2010CommandPrompt = $env:VS100COMNTOOLS + "vcvarsall.bat"
$vs2012CommandPrompt = $env:VS110COMNTOOLS + "VsDevCmd.bat"
 
# Store the VS Command Prompt to do the build in, if one exists.
$vsCommandPrompt = $null
if (Test-Path $vs2012CommandPrompt)
{
    $vsCommandPrompt = $vs2012CommandPrompt
}
elseif (Test-Path $vs2010CommandPrompt)
{
    $vsCommandPrompt = $vs2010CommandPrompt
}
 
# Return the path to the VS Command Prompt if it was found.
return $vsCommandPrompt
}
 
function Get-MsBuildPath
{
 <#
    .SYNOPSIS
    Gets the path to the latest version of MsBuild.exe. Returns $null if a path is not found.
     
    .DESCRIPTION
    Gets the path to the latest version of MsBuild.exe. Returns $null if a path is not found.
#>
 
# Array of valid MsBuild versions
$Versions = @("4.0", "3.5", "2.0")
 
# Loop through each version from largest to smallest
foreach ($Version in $Versions)
{
    # Try to find an instance of that particular version in the registry
    $RegKey = "HKLM:\SOFTWARE\Microsoft\MSBuild\ToolsVersions\${Version}"
    $ItemProperty = Get-ItemProperty $RegKey -ErrorAction SilentlyContinue
 
    # If registry entry exsists, then get the msbuild path and retrun
    if ($ItemProperty -ne $null)
    {
        return Join-Path $ItemProperty.MSBuildToolsPath -ChildPath MsBuild.exe
    }
}
 
# Return that we were not able to find MsBuild.exe.
return $null
}
Export-ModuleMember -Function Invoke-MsBuild

 

# Import the module used to build the .sln and project files.
Import-Module -Name [DirectoryContainingModule]\Invoke-MsBuild.psm1
Invoke-MsBuild -Path "[Path to .sln file]" -MsBuildParameters "/target:Clean;Build /property:Configuration=Release;Platform=""Mixed Platforms"" /verbosity:Quiet"

 

And here’s an example of how to use it (assuming you saved it to a file called “Invoke-MsBuild.psm1”:

If you have any suggestions, please comment.

Feel free to use this in your own scripts.  Happy coding!

Force ClickOnce applications to automatically update without prompting user – Automatically update MinimumRequiredVersion using PowerShell

August 15th, 2012 1 comment

Today I was thinking about using a ClickOnce application in my build process.  The problem is, when using an installed ClickOnce application (as opposed to an online one) if an update to the ClickOnce application is published, the application prompts the user to Accept or Skip downloading and applying the new update.  This would cause a problem for my automated builds as it would end up waiting forever for a user to click Accept.  This post lead me to the answer, which is:

“If your application is an installed application, you can force updates by using the MinimumRequiredVersion attribute. If you publish your application using Visual Studio, you can set this property from the Updates Dialog.”

Just for clarification, the dialog he mentions can be found in Visual Studio in Project Properties->Publish->Updates…  Ok, great.  This will allow the prompt to be suppressed, which is also useful if you don’t want to allow users to skip updates.

There is still a problem however.  Every time I publish a new version of the tool I have to remember to go in and update the MinimumRequiredVersion.  If I forget to do this and then publish another release, the prompt will be back and will ruin my automated builds.

To get around this I created a PowerShell script that keeps the MinimumRequiredVersion up to date, and I call it from a Post-Build event.  This allows me to never have to worry about manually setting the Minimum Required Version, since it gets updated automatically after every successful build.

<EDIT>

I’ve improved upon the powershell script below and created a NuGet package that handles all of the setup/installation for you, as described in my newer post.

</EDIT>

Here is the powershell script:

# Script finds the current ClickOnce version in a project's .csproj file, and updates the MinimumRequiredVersion to be this same version.
# This can be used to force a ClickOnce application to update automatically without prompting the user.

[Parameter(Position=0, HelpMessage="Comma separated paths of the .csproj files to process")]
Param([string]$projectFilePaths)

# If a path to a project file was not provided, grab all of the project files in the same directory as this script.
if (-not($projectFilePaths))
{
# Get the directory that this script is in.
$scriptDirectory = Split-Path $MyInvocation.MyCommand.Path -Parent

# Create comma-separated list of project file paths.
Get-Item "$scriptDirectory\*.csproj" | foreach { $projectFilePaths += "$_,"}
$projectFilePaths = $projectFilePaths.TrimEnd(',')
}

# Catch any unhandled exceptions, write its error message, and exit the process with a non-zero error code to indicate failure.
trap
{
[string]$errorMessage = [string]$_
[int]$exitCode = 1

# If this is one of our custom exceptions, strip the error code off of the front.
if ([string]$errorMessage.SubString(0, 1) -match "\d")
{
$exitCode = [string]$errorMessage.SubString(0, 1)
$errorMessage = [string]$errorMessage.SubString(1)
}

Write-Error $errorMessage
EXIT [int]$exitCode
}

Function UpdateProjectsMinimumRequiredClickOnceVersion
{
Param
(
[Parameter(Mandatory=$true, Position=0, HelpMessage="The project file (.csproj) to update.")]
[string]$projectFilePath
)
if (-not([System.IO.File]::Exists($projectFilePath))) { throw "2Cannot find project file to update at the path: '$projectFilePath'" }

# Build the regular expressions to find the information we will need.
$rxMinimumRequiredVersionTag = New-Object System.Text.RegularExpressions.Regex "\<MinimumRequiredVersion\>(?<Version>.*?)\</MinimumRequiredVersion\>", SingleLine
$rxApplicationVersionTag = New-Object System.Text.RegularExpressions.Regex "\<ApplicationVersion\>(?<Version>\d+\.\d+\.\d+\.).*?\</ApplicationVersion\>", SingleLine
$rxApplicationRevisionTag = New-Object System.Text.RegularExpressions.Regex "\<ApplicationRevision\>(?<Revision>[0-9]+)\</ApplicationRevision\>", SingleLine
$rxVersionNumber = [regex] "\d+\.\d+\.\d+\.\d+"

# Read the file contents in.
$text = [System.IO.File]::ReadAllText($projectFilePath)

# Get the current Minimum Required Version, and the Version that it should be.
$oldMinimumRequiredVersion = $rxMinimumRequiredVersionTag.Match($text).Groups["Version"].Value
$majorMinorBuild = $rxApplicationVersionTag.Match($text).Groups["Version"].Value
$revision = $rxApplicationRevisionTag.Match($text).Groups["Revision"].Value
$newMinimumRequiredVersion = [string]$majorMinorBuild + $revision

# If there was a problem constructing the new version number, throw an error.
if (-not $rxVersionNumber.Match($newMinimumRequiredVersion).Success)
{
throw "3'$projectFilePath' does not appear to have any ClickOnce deployment settings in it."
}

# If we couldn't find the old Minimum Required Version, throw an error.
if (-not $rxVersionNumber.Match($oldMinimumRequiredVersion).Success)
{
throw "4'$projectFilePath' is not currently set to enforce a MinimumRequiredVersion. To fix this in Visual Studio go to Project Properties->Publish->Updates... and check off 'Specify a minimum required version for this application'."
}

# Only write to the file if it is not already up to date.
if ($newMinimumRequiredVersion -eq $oldMinimumRequiredVersion)
{
Write "The Minimum Required Version of '$projectFilePath' is already up-to-date on version '$newMinimumRequiredVersion'."
}
else
{
# Update the file contents and write them back to the file.
$text = $rxMinimumRequiredVersionTag.Replace($text, "<MinimumRequiredVersion>" + $newMinimumRequiredVersion + "</MinimumRequiredVersion>")
[System.IO.File]::WriteAllText($projectFilePath, $text)
Write "Updated Minimum Required Version of '$projectFilePath' from '$oldMinimumRequiredVersion' to '$newMinimumRequiredVersion'"
}
}

# Process each of the project files in the comma-separated list.
$projectFilePaths.Split(",") | foreach { UpdateProjectsMinimumRequiredClickOnceVersion $_ }

The script was actually very small at first, but after commenting it and adding some proper error handling it is fairly large now.

So copy-paste the powershell script text into a new file, such as “UpdateClickOnceVersion.ps1”, and add this file to your project somewhere.

The next step now is to call this script from the Post Build event, so in Visual Studio go into your ClickOnce project’s properties and go to the Build Events tab.  In the Post-build event command line put the following:


REM Update the ClickOnce MinimumRequiredVersion so that it auto-updates without prompting
PowerShell set-executionpolicy remotesigned
PowerShell "$(ProjectDir)UpdateClickOnceVersion.ps1" "$(ProjectPath)"

The first line is just a comment.  The second line may be a security concern, so you might want to remove it.  Basically by default PowerShell is not allowed to run any scripts, so trying to run our script above would result in an error.  To fix this we change the execution policy to allow remotesigned scripts to be ran.  I’m not going to pretend to understand why powershell requires this (as I just started learning it today), but that line only needs to be ran on a PC once, so if you want to remove it from here and just run it from PowerShell manually instead (if you haven’t already ran it before), feel free to do so.  I just include it here so that other developers who build this tool in the future don’t have to worry about this setting.

The third line is where we are actually calling the powershell script, passing the path to the .csproj file to update as a parameter.  I added the powershell script to my project at the root level (so it sits right beside the .csproj file), but you can put the powershell script wherever you like.  Also, you don’t even have to include it in the project if you don’t want to, but I chose to so that it is easily visible for other developers when in Visual Studio, and so that it implicitly gets added to source control.  If you want to put the script in a folder instead of in the root of the project directory feel free; just remember to properly update the path in the post-build events.

So after going through and adding all of my nice error messages to the powershell script, I realized that if there is a problem with the script Visual Studio does not forward the error message to the Output window like I hoped it would; it just spits out the text in the Post-build event window and says an error occurred; which doesn’t really tell us anything.  So if you find you are getting errors, copy-paste that third line into PowerShell, replace the macro variables for their absolute values, and run it there.  Powershell should then give you a much more informative error message.

One last comment about this process is that because the powershell script modifies the .csproj outside of visual studio, after you publish a new version and build, the script will write to that .csproj file and visual studio will give you a prompt that the project was modified outside of visual studio and will want to reload it.  You can choose to reload it (which will close all file tabs for that project), or choose to ignore it; it’s up to you.  This is the one minor annoyance I haven’t been able to find a way around, but it’s still better than having to remember to update the Minimum Required Version manually after every new version of the tool I publish.

I hope you find this post useful, and I appreciate any comments; good or bad.  Happy coding!