Changing metadata in MP3 files, and using .NET assemblies

Manipulating MP3 metadata is unlikely to be directly relevant to work activities, but trying to learn more about how to do this in PowerShell has helped me learn more about using .NET assemblies, which has many other applications and is very likely to be useful in future. I’ve used .NET assemblies before, such as in my old script for estimating Windows 8 network usage, but usually by reusing or slightly adapting someone else’s work, rather than starting by myself from scratch.

I’ll begin by explaining exactly what I was trying to do. I recently downloaded some free albums from the Adult Swim website, but had found that the metadata for the files was inconsistent and wouldn’t play nicely with my MP3 player. I wasn’t concerned about most attributes – my focus was on the track number, track title, album artist, contributing artist/performer, and album title attributes. Since all of these attributes can be made visible within the Explorer shell in Windows 7, I assumed it would be possible to modify them without too much difficulty. I assumed wrong.

Doing some initial reading, I found this post, and thought that it would provide a good basis for what I needed to do. Unfortunately, while it describes a clever approach for reading metadata, it doesn’t provide a method for writing metadata. Subsequent reading revealed that writing MP3 metadata would require using a third-party DLL. Enter the taglib-sharp library.

Focusing on this particular library, it became clear from a variety of blog posts and questions on StackOverflow that this was the way to go. I even found this blog post describing a PowerShell module built around this very DLL, but I decided that this was a good excuse to figure out how to make it work for myself. Along the way I’d be able to start getting a better grip on how to use .NET assemblies and pull back the covers to see what’s going on beneath the cmdlet level.

To start with, the DLL needs to be loaded. There are two ways of doing this  – you can use the Reflection assembly to load it:

[System.Reflection.Assembly]::LoadFile("$($dllpath)\taglib-sharp.dll")

or you can use the Add-Type cmdlet (introduced in PowerShell v3.0):

Add-Type -Path "$($dllpath)\taglib-sharp.dll"

Now that the DLL is available, what next? At first, I didn’t know what to do next – and while that’s fine in this context (because the blog posts I had found would probably tell me what to do, or in the case of the module I mentioned, do it for me) in a wider context it renders the DLL useless. So in the more general context, the next step is to examine the DLL itself and find out what methods it provides. In my case, I used Visual Studio Community Edition 2013 to open the DLL – with no useful result:

01. Opening taglib-sharp.dll in Visual Studio

02. Contents of taglib-sharp.dll in Visual Studio

A bit more poking around on StackOverflow informed me that the Object Browser (located under the View menu in this version of VS) is what I was looking for:

03. Examining the taglib-sharp.dll object in Visual Studio

04. Contents of the taglib-sharp.dll object in Visual Studio

Click to see a full-size version

Much more useful.

So the first thing we need is to create an object in PowerShell of the relevant type. Examining the File class, there are a number of methods – including a “Create” method which accepts a simple string as its input:

$obj=[Taglib.File]::Create($filepath)

At this point it becomes easier to use existing PowerShell knowledge to explore further. For example, the object can be piped to Get-Member to see its methods and properties:

$obj | Get-Member

05. Get-Member results for a Taglib object

Examining the $obj property “Properties”, there’s not a lot of useful information:
06. Object Properties

But the property “Tag” provides what I’m looking for:

07. Object Tag

I now have enough information to start experimenting – I know where to find the object properties containing the information to be updated, and from both the File class definition in Visual Studio and the output of piping the object to Get-Member, I know that a method called Save() exists which is most likely to be what I’ll use to save changes to the file.

In my case, I looked up the album information on the Ghostly Records website and renamed the files with the convention “Track Number with leading 0 – Artist – Track title.mp3”, using the following:

$titles=@("Michna - Triple Chrome Dipped","Dabrye - Temper","The Chap - Carlos Walter Wendy Stanley","Dark Party - Active","Tycho - Cascade [Live Version]","JDSY - All Shapes","Deastro - Light Powered","Matthew Dear - R+S","FLYamSAM - The Offbeat","Cepia - Ithaca","Aeroc - Idiom","The Reflecting Skin - Traffickers","School of Seven Bells - Chain","Ben Benjamin - Squirmy Sign Language","Kill Memory Crash - Hit and Run","Osborne - Wait A Minute","Milosh - Then It Happened","1032 - Blue Little","Mux Mool - Night Court")
$songs=Gci "C:\Users\Kyle\Downloads\ghostlyswim" | Where-object {$_.Name -like "*.mp3"}

[int]$i=01
foreach ($t in $titles) {
	foreach ($s in $songs) {
		if ($t -match ($s.Name.Substring(0,$($s.Name.Length -4)).Replace("_"," - "))) {
			if ($i.ToString().Length -eq 1) {
				[string]$newname="0"+$i.ToString()+" - "+$t+".mp3"
			} else {
				[string]$newname=$i.ToString()+" - "+$t+".mp3"
			}
			Rename-Item -Path $s -NewName $newname
		}
	}
	$i++
}

I mention this because this means I can generate the desired metadata for the files from the directory and filenames. So I can create a function to take a directory path as input, get a list of all matching files in that path and generate the new metadata for each file using the following:

	[int]$trackno=($filename -split " - ")[0]
	[string]$artist=($filename -split " - ")[1]
	[string]$title=(($filename -split " - ")[2]).TrimEnd(".mp3")
	[string]$album=($dirname -split "\\")[$([int]($dirname -split "\\").count -1)]

With that done, it’s just a case of updating the properties for the $obj object created earlier, then invoking the Save() method. Right? Wrong.

For reasons I don’t fully understand, the approach I’ve described works – but the resultant updated tags would still not display correctly within the Windows 7 Explorer shell. Checking the metadata in a media application or using the DLL in PowerShell confirmed that the changes had been made, but for whatever reason they were not displaying in Windows. So back to Google I went.

After a little reading around I found a number of people claiming that Windows 7 does not play nice with Id3v2.4 tags (which surprised me chiefly because I’d never bothered to investigate the nature of Id3 tags at all, and did not realise there are multiple versions of Id3v2). I haven’t been able to conclusively prove that this was the root of my issue, but it seemed worthwhile to pursue it as a possible cause.

Looking at the output of piping the $obj object to Get-Member again, I noticed the RemoveTag() method – which works fine for removing a tag, but there’s no equivalent AddTag() method. Continuing to nose around in Visual Studio, I found that the Tag class has a method called Clear(), which looked like it might be worth trying. I also found that the namespaces for the various types of tag also contain their own Tag class. The Id3v2 namespace contains a variety of methods used for manipulating the frames within a given file’s tag. I found that using the Clear() method to remove the tag contents before assigning my desired values was enough to resolve the issue, but in the event of the issue persisting my next step probably would have been to learn more about the nature and structure of Id3v2 tag frames, then use the various methods I’ve mentioned above to remove the existing tag and create a new one with the required frames added and populated.

I’ve since been able to confirm that it is specifically Id3v2.4 tags which cause the problem, and found a simpler solution than I had anticipated. I’ve updated my script below to reflect this, but the pertinent parts are as follows:

Within the Tag property, a GetTag() method exists. Calling this method for the Id3v2 tag on the object will return the following:
08. Object Id3v2 Tag

This doesn’t make it obvious that there’s anything useful within the tag, but switching back to Visual Studio and searching for “Version” returns a number of results, including what I was looking for:
09. taglib-sharp version search

Click for full size image

Click for full size image

Some testing reveals that the Version attribute can’t be set directly, but the tried-and-tested method of casting the tag to a new object, modifying the object, then assigning the values from that object back to the original $obj will work:

$newtag=$global:obj.Tag.GetTag("Id3v2")
$newtag.Version="3"
$global:obj.Tag.SetTags($newtag)

At which point, any other metadata changes can be processed as normal and then saved with the Save() method. I have replaced the section of my script which uses the Clear() method with logic that checks for the presence of an Id3v2.4 tag and attempts to change the version to Id3v2.3.

I’ll end this post with the full script that I wrote for updating metadata in this fashion. I’ll probably still develop this script further to add more flexibility to it like logic for identifying filename patterns and matching them against metadata patterns, proper recursion support, and possibly support for reading metadata from an input file. But for now it’s enough for my needs.

In my next post, I’ll write about some more interesting tricks I learned while writing this script, around how to understand what’s happening at a deeper level when you execute a specific cmdlet in PowerShell and access options and methods that don’t appear to be available on first inspection.

Function Update-Metadata {
	param(
		[string]$filename,
		[string]$dirname,
		[boolean]$fixtag
	)
	
	$global:obj=[taglib.File]::Create($($dirname+"\"+$filename))
	$props = $global:obj.Tag | Get-Member -MemberType Property
	[boolean]$changed=$false
	
	# If "fixtag" boolean is set to true, the tag version information is updated before other attributes are checked.
	if ($fixtag) {
		foreach ($t in $global:obj.Tag.TagTypes) {
			if (($t -match "Id3v2") -and ($global:obj.Tag.GetTag("Id3v2").Version -eq "4")) {
				Write-Host "Id3v2.4 tag found."
				"Id3v2.4 tag found." | Out-File -Filepath $global:logfile -append
				try {
					$newtag=$global:obj.Tag.GetTag("Id3v2")
					$newtag.Version="3"
					$global:obj.Tag.SetTags($newtag)
					$changed=$true
				} catch {
					Write-Host "An Id3v2.4 tag was found, but the version could not be changed to Id3v2.3." -foregroundcolor red
					"An Id3v2.4 tag was found, but the version could not be changed to Id3v2.3" | Out-File -Filepath $global:logfile -append
				}
			}
		}
	}

	[int]$trackno=($filename -split " - ")[0]
	[string]$artist=($filename -split " - ")[1]
	[string]$title=(($filename -split " - ")[2]).TrimEnd(".mp3")
	[string]$album=($dirname -split "\\")[$([int]($dirname -split "\\").count -1)]
	
	Write-Host "New Metadata items:`nTrack #:`t$($trackno)`nArtist:`t$($artist)`nTitle:`t$($title)`nAlbum:`t$($album)"
	$filename,$dirname | Out-File -Filepath $global:logfile -append
	"New Metadata items:","Track #:`t$($trackno)","Artist:`t$($artist)","Title:`t$($title)","Album:`t$($album)" | Out-File -Filepath $global:logfile -append
	
	#Fields to update: Title,Album Artist,Contributing Artists, Track number, Album Title
	# Track number
	if (!$global:obj.Tag.Track -or ($global:obj.Tag.Track -ne $trackno)) {
		$global:obj.Tag.Track=$trackno
		$changed=$true
		Write-Host "Track number updated."
		"Track number updated." | Out-File -Filepath $global:logfile -append
	} else {
		Write-Host "Track number does not need to be updated."
		"Track number does not need to be updated." | Out-File -Filepath $global:logfile -append
	}
	# Album title
	if (!$global:obj.Tag.Album -or ($global:obj.Tag.Album -ne $album)) {
		$global:obj.Tag.Album=$album
		$changed=$true
		Write-Host "Album title updated."
		"Album title updated." | Out-File -Filepath $global:logfile -append
	} else {
		Write-Host "Album title does not need to be updated." 
		"Album title does not need to be updated." | Out-File -Filepath $global:logfile -append
	}
	# Album artist
	if (!$global:obj.Tag.AlbumArtists -or ($global:obj.Tag.AlbumArtists -ne $artist)) {
		$global:obj.Tag.AlbumArtists=$artist
		$changed=$true
		Write-Host "Album artist attribute updated."
		"Album artist attribute updated." | Out-File -Filepath $global:logfile -append
	} else {
		Write-Host "Album artist attribute does not need to be updated."
		"Album artist attribute does not need to be updated." | Out-File -Filepath $global:logfile -append
	}
	# Contributing artist
	if (!$global:obj.Tag.Performers -or ($global:obj.Tag.Performers -ne $artist)) {
		$global:obj.Tag.Performers=$artist
		$changed=$true
		Write-Host "Performers attribute updated."
		"Performers attribute updated." | Out-File -Filepath $global:logfile -append
	} else {
		Write-Host "Performers attribute does not need to be updated."
		"Performers attribute does not need to be updated." | Out-File -Filepath $global:logfile -append
	}
	# Track title
	if (!$global:obj.Tag.Title -or ($global:obj.Tag.Title -ne $title)) {
		$global:obj.Tag.Title=$title
		$changed=$true
		Write-host "Track title updated"
		"Track title updated" | Out-File -Filepath $global:logfile -append
	} else {
		Write-Host "Track title does not need to be updated."
		"Track title does not need to be updated." | Out-File -Filepath $global:logfile -append
	}
	Write-Host " "
	# Save change
	if ($changed) {
		try {
			$global:obj.Save()
			Write-Host "Metadata changes have been saved for $($filename)." -foregroundcolor green
			"Metadata changes have been saved for $($filename)." | Out-File -Filepath $global:logfile -append
		} catch {
			Write-Host "An error occured while updating Metadata for $($filename); changes have not been saved." -foregroundcolor red
			"An error occured while updating Metadata for $($filename); changes have not been saved." | Out-File -Filepath $global:logfile -append		
		}
	} else {
		Write-Host "No Metadata changes are required for $($filename)." -foregroundcolor green
		"No Metadata changes are required for $($filename)." | Out-File -Filepath $global:logfile -append	
	}
	Remove-Variable -Name filename,dirname,obj,props,trackno,artist,album,title,changed -Force -ErrorAction SilentlyContinue
}

# Main body

Write-Host "Update MP3 Metadata" -foregroundcolor green
$global:logfile=$PSScriptRoot+"\Update-MP3Metadata.log"
if (!(Test-Path $logfile)) {
	"Started Update-MP3Metadata script..." | Out-File -Filepath $global:logfile
} else {
	"Started Update-MP3Metadata script..." | Out-File -Filepath $global:logfile -append
}

# Check if taglib-sharp.dll is available, and attempt to load it:
[boolean]$loaded=$false
while (!$loaded) {
	if ($dllpath) {
		try {
			[System.Reflection.Assembly]::LoadFile("$($dllpath)\taglib-sharp.dll")
			[boolean]$loaded=$true
			break;
		} catch {
			Write-Host "Couldn't load DLL from $($dllpath)!"
			"Couldn't load DLL from $($dllpath)!" | Out-File -Filepath $global:logfile -append
		}
	} else {
		if (Test-Path "$PSScriptRoot\taglib-sharp.dll") {
			$dllpath=$PSScriptRoot+"\taglib-sharp.dll"
			try {
				[System.Reflection.Assembly]::LoadFile("$dllpath") | Out-Null
				[boolean]$loaded=$true
				break;
			} catch {
				Write-Host "Couldn't load DLL from $($dllpath)!"
				"Couldn't load DLL from $($dllpath)!" | Out-File -Filepath $global:logfile -append
			}
		} else {
			$PF=gci $env:programfiles -Directory -Filter "*taglib-sharp*"
			if ($PF) {
				$dllpath=(gci -recurse $PF.Fullname | Where-Object {$_.Name -like "taglib-sharp.dll"}).FullName
				if ($dllpath) {
					try {
						[System.Reflection.Assembly]::LoadFile("$($dllpath)") | Out-Null
						[boolean]$loaded=$true
						break;
					} catch {
						Write-Host "Couldn't load DLL from $($dllpath)!"
						"Couldn't load DLL from $($dllpath)!" | Out-File -Filepath $global:logfile -append
					}
				} else {
					Write-Host "Could not find required DLL in $($PF.Fullname)"
					"Could not find required DLL in $($PF.Fullname)" | Out-File -Filepath $global:logfile -append
				}
			}
			if ([System.Environment]::Is64BitOperatingSystem) {
				$PFx86=gci ${env:programfiles(x86)} -Directory -Filter "*taglib-sharp*"
				if ($PFx86) {
					$dllpath=(gci -recurse $PFx86.Fullname | Where-Object {$_.Name -like "taglib-sharp.dll"}).FullName
					if ($dllpath) {
						try {
							[System.Reflection.Assembly]::LoadFile("$($dllpath)") | Out-Null
							[boolean]$loaded=$true
							break;
						} catch {
							Write-Host "Couldn't load DLL from $($dllpath)!"
							"Couldn't load DLL from $($dllpath)!" | Out-File -Filepath $global:logfile -append
						}
					} else {
						Write-Host "Could not find required DLL in $($PFx86.Fullname)"
						"Could not find required DLL in $($PFx86.Fullname)" | Out-File -Filepath $global:logfile -append					
					}
				}
			}
		}
	}
}

if ($loaded) {
	[boolean]$validdir=$false
	while (!$validdir) {
		[string]$musicdir=Read-Host("Please enter the full path of the directory in which you want to update Metadata")
		If (Test-Path $musicdir) {
			Write-Host "Found path $($musicdir)." -foregroundcolor green
			"Found path $($musicdir)." | Out-File -Filepath $global:logfile -append
			$validdir=$true
		} else {
			Write-Host "Invalid path entered, please try again." -foregroundcolor red
			"Path $($musicdir) is invalid." | Out-File -Filepath $global:logfile -append
		}
	}
	[boolean]$validchoice=$false
	while (!$validchoice) {
		[string]$choice=Read-Host("Ensure Id3v2 tags are version 2.3? This ensures that metadata can be displayed within the Windows Explorer shell.  Y/N")
		switch ($choice) {
			{$_ -like "Y"} {
				[boolean]$fixtag=$true
				Write-Host "Id3v2 tags will be updated to version 2.3."
				"Id3v2 tags will be updated to version 2.3." | Out-File -Filepath $global:logfile -append
				$validchoice=$true
			}
			{ $_ -like "N"} {
				[boolean]$fixtag=$false
				"Id3v2 tag versions will not be checked or modified." | Out-File -Filepath $global:logfile -append
				$validchoice=$true			
			}
			default {
				Write-Host "Invalid option entered, please try again." -foregroundcolor red
				Start-Sleep 5
				CLS
			}
		}
	}
	Remove-Variable -Name validdir,validchoice -Force -ErrorAction SilentlyContinue
	cd $musicdir
	Write-Host "Working directory:`t$($musicdir.FullName)"
	$filelist=gci $musicdir -Filter "*.mp3"
	foreach ($f in $filelist) {
		Write-Host "`nFile:`t$($f.Name):`n"
		Update-Metadata $f.Name $musicdir $fixtag
	}
} else {
	Write-Error "Required DLL taglib-sharp.dll could not be found on the system; script will now exit." | Out-File -Filepath $global:logfile -append
}
Remove-Variable -Name loaded,dllpath,PF,PFx86,musicdir,filelist,fixtag -Force -ErrorAction SilentlyContinue

Disable the Get-Windows 10 Agent, Part 3

In my previous posts about the GWX agent, I provided a list of steps required for removing the agent and discussed how to handle the first half of them:

  1. Verify that the current session is running with administrative privileges
  2. Install (if necessary) and load the PSWindowsUpdate module.
  3. Create a restore point
  4. Kill any running GWX-related processes.
  5. Uninstall KB3035583 (and any other GWX-related updates).
  6. Check if reboot is required and schedule hiding the updates.
  7. Configure policy for disabling OS upgrade display in the Windows Update interface.

Along the way I defined functions for downloading & extracting compressed files, and for creating a scheduled task for post-reboot actions.

In this post I will primarily be discussing the remaining tasks, but I will first revisit some of the previously-defined functions. This is necessary as I have since modified them to either work around certain issues or make them more flexible.

Revised functions

I’ve updated Part 1‘s Get-UpdateModule function slightly – it was already a simple file-download function but the logging was specific to the PSWindowsUpdate module. I have also updated the Extract-Zip function more significantly – it now supports partial extraction of files based on filename, as well as a post-extraction cleanup to remove the source zip file. The revised version of the function is as follows:

# A generalised function for extracting compressed files to a specified location.
Function Extract-Zip {
    param(
        [string]$file,
        [string]$location,
        [array]$extractlist,
        [boolean]$cleanup=$false
    )
    if (!(Test-Path $location)) {
        try {
            mkdir $location | out-Null
        } catch {
            Write-Host "Unable to create folder $location, error was:`n$($_.Exception.Message)" -foregroundcolor red
			"Unable to create folder $location, error was:`n$($_.Exception.Message)" | Out-file -Filepath $global:logfile -append
        }
    }
    if ($extractlist) {
        Write-Host "Specific file extraction selected. Only the following files will be extracted:";$extractlist
    } else {
        Write-Host "Default mode selected, extracting all files..."
    }
    if ((Test-Path $file) -and (Test-Path $location)) {
        # Instantiate a new shell object and namespace
        $shell=New-Object -com Shell.Application
        $zip=$shell.NameSpace($file)
        # Check if the $extractlist parameter is set, and extract files accordingly.
        if (!$extractlist) {
            # Extract list is not set so default to extracting all files.
            try {
                foreach ($item in $zip.items()) {
                    $shell.Namespace($location).Copyhere($item)
                }
                Write-Host "Finished extracting contents of $file to $location." -foregroundcolor green
			    "Finished extracting contents of $file to $location." | Out-file -Filepath $global:logfile -append
            } catch {
                Write-Host "An error occured while extracting the contents of $file to $location; the error message was:`n$($_.Exception.Message)" -foregroundcolor red
			    "An error occured while extracting the contents of $file to $location; the error message was:", "`n", "$($_.Exception.Message)" | Out-file -Filepath $global:logfile -append
            }
        } else {
            # Extract list is set, so iterate through each name in the array and extract that file from the zip. Items in extractlist are not assumed to be unique matches, so a list of matching contents is generated for each item and a foreach loop iterates through the list, extracting each match individually.
            foreach ($e in $extractlist) {
                $list=@($zip.Items() | Where-Object {$_.Name -like $e})
                if ($list) {
                    foreach ($l in $list) {
                        try {
                            $shell.Namespace($location).Copyhere($l)
                            Write-Host "Extracted file $($e) successfully." -foregroundcolor green
                            "Finished extracting contents of $file to $location." | Out-file -Filepath $global:logfile -append
                        } catch {
                            Write-Host "Unable to extract file $($e), error was:`n$($_.Exception.Message)" -foregroundcolor red
                            "Unable to extract file $($e), error was:",$_.Exception.Message | Out-file -Filepath $global:logfile -append
                        }
                    }
                } else {
                    Write-Host "No file with name $($e) found in specified archive." -foregroundcolor yellow
                    "No file with name $($e) found in specified archive." | Out-file -Filepath $global:logfile -append
                }
				Remove-Variable -Name list -Force -ErrorAction SilentlyContinue
            }
        }
		if ($cleanup) {
			Write-Host "Cleanup enabled: deleting compressed file..." -foregroundcolor Green
			Remove-Item -Path $file -Force 
		}		
    } else {
        Write-Host "Unable to proceed with extraction, invalid input specified!" -foregroundcolor red
		"Unable to proceed with extraction, invalid input specified!" | Out-file -Filepath $global:logfile -append
        if (!(Test-Path $file)) {
            Write-Host "Could not find file $file!" -foregroundcolor red
			"Could not find file $file!" | Out-file -Filepath $global:logfile -append
			
        }
        if (!(Test-Path $location)) {
            Write-Host "Could not find or create folder path $location!" -foregroundcolor red
			"Could not find or create folder path $location!" | Out-file -Filepath $global:logfile -append
        }
    }
}

I’ve also revised Part 2‘s Schedule-HideUpdates function to use a job for each attempt at hiding an update. This was motivated by issues I have been experiencing when testing my script against a fresh install of Windows 7 SP1, whereby Windows Update appears not to detect updates unless specific patches are manually applied.

To avoid a situation of the non-interactive powershell instance started by the scheduled task running indefinitely, I restructured the job so that it first checks whether the update is available from Windows Update and then attempts to hide it. Each job is passed through Wait-Job to ensure that it has 5 minutes in which to run – this should be plenty of time for querying Windows Update and completing the hide request on a normal machine.

If the job is not complete after that time, the log file is updated to record this and the job is forcibly terminated. I have also made the invocation of this function a non-default parametrised option on the main script.

The revised function is as follows:

Function Schedule-HideUpdates {
	param(
		[array]$updatelist,
		[string]$scriptroot
	)
	# Export the array of updates to be hidden to a text file.
    $updatelist | Out-file -Filepath $($scriptroot+"\kbarray.txt")
	# Create a here-string containing the script to be executed post-rebooot to hide the required updates.
$taskscript=@"
`$global:logfile="$($global:logfile)"
(Get-Date -format "yyyy-mm-dd_HH:mm")+": Started post-reboot task to hide GWX-related updates in Windows Update." | Out-File -Filepath `$global:logfile -append
ipmo PSWindowsUpdate
`$timeout=300
`$kbarray=Get-Content "$($scriptroot+"\kbarray.txt")"
foreach (`$kbnumber in `$kbarray) {
	`$job = Start-Job -ScriptBlock {
		`$kbnumber=`$args[0];
		`$global:logfile=`$args[1];
		ipmo PSWindowsUpdate; 
		if (Get-WUList -KBArticleID `$kbnumber) {
			"Update `$kbnumber available from Windows Update, attempting to hide..." | Out-file -Filepath `$global:logfile -append
			try {
				Hide-WUUpdate -KBArticleID `$kbnumber -Confirm:`$false -ErrorAction Stop
				"Update `$kbnumber should now be hidden." | Out-file -Filepath `$global:logfile -append
			} catch {
				"Unable to hide update `$kbnumber, error was:",`$_.Exception.Message | Out-file -Filepath `$global:logfile -append			
			}
		} else {
			"Update `$kbnumber is not being offered by Windows Update, no action needed." | Out-file -Filepath `$global:logfile -append	
		}
	} -argumentlist $kbnumber,$global:logfile
	Wait-Job `$job -Timeout `$timeout | Out-Null;
	if (`$job.State -ne "Completed") {
		Write-Host "Job seems to have stalled, force-quitting..." -foregroundcolor red; 
		Stop-Job `$job -Confirm:`$false
		Remove-Job `$job
	} else {
		Write-Host "Job completed successfully" -foregroundcolor green
	}		
}
"Finished hiding updates.","" | Out-file -Filepath `$global:logfile -append
# Remove scheduled task so that it doesn't run again.
`$deletetask='schtasks /delete /tn "Hide GWX Updates" /f'
`$d=Start-Process -Filepath `$env:systemroot\system32\cmd.exe -ArgumentList "/c `$deletetask" -Wait -NoNewWindow -Passthru
if (`$d.ExitCode -eq 0) {
	"Successfully deleted scheduled task for hiding GWX-related updates." | Out-File -Filepath `$global:logfile -append
} else {
	"Unable to delete scheduled task for hiding GWX-related updates." | Out-File -Filepath `$global:logfile -append
}
"@
	# Output the script to a file in the script root location.
	$scriptfile=$scriptroot+"\HideGWXUpdates.ps1"
	$taskscript | Out-file -Filepath $scriptfile
	
	# Create a new scheduled task as the current user
	Write-Host "Creating scheduled task to run at next logon..."
	$password=Read-Host -Prompt "Please enter the password for $($env:username)" -AsSecureString
	$credential=New-Object -TypeName System.Management.Automation.PSCredential $env:username,$password
	
	$createtask='schtasks /create /tn "Hide GWX Updates" /tr "C:\windows\system32\WindowsPowerShell\v1.0\powershell.exe -File \"'+$scriptfile+'\"" /rl highest /sc onlogon /ru "'+$($env:username)+'" /rp "'+$($credential.GetNetworkCredential().Password)+'"'
	Remove-Variable -Name password,credential -Force -ErrorAction SilentlyContinue

	$c=Start-Process -Filepath $env:systemroot\system32\cmd.exe -ArgumentList "/c $createtask" -Wait -NoNewWindow -Passthru

	Remove-Variable -Name createtask -Force -ErrorAction SilentlyContinue
	
	if ($c.ExitCode -eq 0) {
		Write-Host "Successfully created scheduled task for hiding GWX-related updates."
		"Successfully created scheduled task for hiding GWX-related updates." | Out-File -Filepath $global:logfile -append
	} else {
		Write-Host "Failed to create scheduled task for hiding GWX-related updates."
		"Failed to create scheduled task for hiding GWX-related updates." | Out-File -Filepath $global:logfile -append	
	}
	Remove-Variable -Name createtask,c -Force -ErrorAction SilentlyContinue
}

New functions

That covers the main changes to the code from my previous posts, so it’s time to look at the remaining steps for the script overall. They are:

8. Take ownership of C:\Windows\System32\GWX, from TrustedInstaller
9. Give Administrators group full control of the folder
10. Rename the GWX folder to something else
11. Replace it with an empty folder named GWX and deny access to TrustedInstaller
12. Take a full backup of the whole registry now, to be paranoid, regedit again, find the GWX registry keys and delete them
13. Delete all the GWX scheduled tasks.

Steps 8-11 are all dealt with by the Restrict-Folder function. It takes three parameters – the directory path to be restricted, a user group to which ownership can be transferred, and a user to whom access will be denied.

Function Restrict-Folder {
    param(
        [string]$directory,
		[string]$group,
        [string]$user
    )
    if ($directory) {
        $global:directory=$directory
    } else {
        $valid=$false
        while (!$valid) {
            $global:directory=Read-Host("Enter the directory to be restricted")
            if (Test-Path $global:directory) {
                $valid=$true
            } else {
                Write-Host "Specified directory is invalid, please try again." -foregroundcolor red
            }
        }
    }
    if ($group) {
        $global:group=$group
    } else {
		$valid=$false
		while (!$valid) {
			$global:group=Read-Host("Enter the group name for the user account to block, e.g. NT Service or $($env:computername)")
			if ([ADSI]::Exists('WinNT://./$global:group')) {
				$valid=$true
			} else {
                Write-Host "Specified group does not exist, please try again." -foregroundcolor red			
			}
		}
    }	
    if ($user) {
        $global:user=$user
    } else {
		$valid=$false
		while (!$valid) {
			$global:user=Read-Host("Enter the username for the user account to block, e.g. TrustedInstaller, or $($env:Username)")
			if ([ADSI]::Exists('WinNT://./$global:user')) {
				$valid=$true
			} else {
                Write-Host "Specified user does not exist, please try again." -foregroundcolor red			
			}
		}			
    }

The initial block here handles the parameters and checking that they are valid; if no value is passed, the user is prompted for values. These values are then tested for validity – in the case of the directory Test-Path checks that it exists, and in the case of the user and group an ADSI static method is used to check the users and groups on the local machine (a very useful trick I discovered from [url=http://powershell.com/cs/blogs/tips/archive/2010/12/30/checking-whether-user-or-group-exists.aspx]this page[/url]. This method assumes a standalone workstation – for domain-bound systems the same method could be used replacing the WinNT check with an LDAP check; the ActiveDirectory PS module also provides another method for querying user and group membership.

    Write-Host "Attempting to reconfigure the specified directory..."
	"Attempting to reconfigure the specified directory..." | Out-file -Filepath $global:logfile -append
    $NewOwner=New-Object System.Security.AccessControl.DirectorySecurity
    $admin=New-Object System.Security.Principal.NTAccount("BUILTIN","Administrators")
    $NewOwner.SetOwner($admin)

    $folder="\\localhost\\"+($global:directory -split ":")[0]+"$"+(($global:directory -split ":")[1] -replace "\\","\\")
    [System.IO.Directory]::SetAccessControl($folder,$NewOwner)

This block handles the creation of System Security objects that can be used to manipulate the security descriptor for the target directory. Notice that the $admin object is hard-coded to represent the security group BUILTIN\Administrators – this could be trivially generalised with additional parameters to allow the function to assign new ownership as desired. After creating the $admin object, it is used to set the Owner attribute of the $NewOwner object.

You may be wondering why I didn’t just use Set-ACL for this task. I found that attempting to change the folder ownership via Set-ACL on Windows 7 when the account being used is not already the owner of the folder resulted in “Permission Denied” errors; using the SetAccessControl method from the [url=https://msdn.microsoft.com/en-us/library/system.io.directory%28v=vs.110%29.aspx]System.IO.Directory .NET class[/url] does not trigger these errors.

At this point, steps 8 and 9 of the list are complete.

    $rand=$global:directory+"."+(Get-Random).ToString()
    try {
        Rename-Item $global:directory $rand
        Write-Host "Renamed existing directory to $($rand)." -foregroundcolor green
		"Renamed existing directory to $($rand)." | Out-file -Filepath $global:logfile -append
    } catch {
        Write-Host "Unable to rename existing directory, error was:`n$($_.Exception.Message)" -foregroundcolor red
		"Unable to rename existing directory, error was:","$($_.Exception.Message)" | Out-file -Filepath $global:logfile -append
    }

Once ownership of the directory has been changed, the directory is renamed by adding a randomised suffix. During testing I found that this operation would sometimes fail due to an unspecified pending task relating to the directory; the most reliable way to avoid this appears to be to run the script shortly after a reboot, but I haven’t bothered investigating further (though I suspect Process Explorer & Process Monitor would quickly pinpoint the root cause).

At this point, step 10 is complete.

    if (Test-Path $global:directory) {
		Write-Host "Original GWX directory has not been renamed." -foregroundcolor red
		"Original GWX directory has not been renamed." | Out-file -Filepath $global:logfile -append
    } else {
		Write-Host "Original directory has been renamed. Creating new, empty GWX directory..." -foregroundcolor green
		"Original directory has been renamed. Creating new, empty GWX directory..." | Out-file -Filepath $global:logfile -append
		New-Item $global:directory -Type Directory -Force
	}
	try {
		$colRights = [System.Security.AccessControl.FileSystemRights]"Read"
		$InheritanceFlag = [System.Security.AccessControl.InheritanceFlags]::None
		$PropagationFlag = [System.Security.AccessControl.PropagationFlags]::None
		$objType =[System.Security.AccessControl.AccessControlType]::Allow
		$objUser = New-Object System.Security.Principal.NTAccount($global:group,$global:user)
		$remACE = New-Object System.Security.AccessControl.FileSystemAccessRule ($objUser, $colRights, $InheritanceFlag, $PropagationFlag, $objType)

		$colRights = [System.Security.AccessControl.FileSystemRights]"FullControl"
		$objType =[System.Security.AccessControl.AccessControlType]::Deny
		$denyACE = New-Object System.Security.AccessControl.FileSystemAccessRule ($objUser, $colRights, $InheritanceFlag, $PropagationFlag, $objType)  
	  
		$objGroup = New-Object System.Security.Principal.NTAccount("BUILTIN","Administrators")
	  
		$objACL = Get-ACL $global:directory

		$objACL.SetAccessRuleProtection($true,$true)
	  
		$objACL.RemoveAccessRuleAll($remACE)
	  
		$objACL.AddAccessRule($denyACE)
		
		$objACL.SetOwner($objGroup)
	  
		Set-ACL -Path $global:directory -Aclobject $objACL
		Write-Host "Finished reconfiguring directory $($global:directory). The account $($global:user) should no longer have access to this directory."
		"Finished reconfiguring directory $($global:directory). The account $($global:user) should no longer have access to this directory." | Out-file -Filepath $global:logfile -append
	} catch {
		Write-Host "Unable to complete reconfiguration of directory $($global:directory). The error was:`n$($_.Exception.Message)." -foregroundcolor red
		"Unable to complete reconfiguration of directory $($global:directory). The error was:","$($_.Exception.Message)." | Out-file -Filepath $global:logfile -append
	}
}

After renaming the original folder a new, empty folder is created and it is this folder to which the TrustedInstaller account will be denied access.

Next, I created two new Access Control Entries – one to remove the existing “Allow Read” access, and another to add “Deny FullControl” access. To do this, I started from the instructions in [url=https://technet.microsoft.com/en-us/library/ff730951.aspx]this PowerShell Tip of the Week article[/url] and made a few changes as necessary. Having created the ACEs I then read the ACL from the newly-created GWX directory and used its built-in methods to apply the new ACEs, ensure that the new owner was set as “Builtin\Administrators”, and lastly apply the modified ACL object to the directory.

At this point, step 11 is complete.

Function Remove-RegistryKeys {
    param(
        [array]$locations,
        [string]$key,
		[string]$psexec
    )
	$sortedlist=@()
	foreach ($location in $locations) {
		if ((Test-Path $location) -and ($key)) {
			$list=Get-ChildItem -path $location

			foreach ($l in $list) {
				if ($l.PSPath) {
					$pspath=$l.PSPath
				} else {
					$pspath=$null
				}
				try {
					$taskpath=(Get-ItemProperty -Path $l.PSPath -Name Path -ErrorAction Stop).Path
				} catch {
					$taskpath=$null
				}	
				if ($l.Name) {
					$name=$l.Name
				} else {
					$name=$null
				}
				$obj = New-object -TypeName PSObject -Property @{"Name"=$name;"TaskPath"=$taskpath;"PSPath"=$pspath}
				$sortedlist+=$obj
				Remove-Variable -Name obj -Force -ErrorAction SilentlyContinue
			}
			Remove-Variable -Name list -Force -ErrorAction SilentlyContinue
		} else {
			Write-Host "Unable to proceed with registry key deletion for location $location, invalid input specified!" -foregroundcolor red
			"Unable to proceed with with registry key deletion for location $location, invalid input specified!" | Out-file -Filepath $global:logfile -append
			if (!(Test-Path $location)) {
				Write-Host "Could not find registry location $location!" -foregroundcolor red
				"Could not find registry location $location!" | Out-file -Filepath $global:logfile -append
				
			}
			if (!$key) {
				Write-Host "No search key specified!" -foregroundcolor red
				"No search key specified!" | Out-file -Filepath $global:logfile -append
			}		
		}
	}

Step 12 has multiple tasks folded into one, so we’ll start with them here. The first task should be creating a registry backup – however, as detailed in Part 1, the script creates a System Restore snapshopt at the beginning of the operation, we don’t need to perform any additional work here. The remainder of the task is to find and remove the GWX-related keys.

The function fragment above shows that three parameters are accepted – an array of locations, a key and a psexec string. The latter string is used to specify the location of the SysInternals PsExec.exe executable – this is used to allow running tasks as the System user. While testing the script I encountered several permission restrictions when removing keys and scheduled tasks; using PsExec was the most efficient way around the problem – and since I already had functions that could take care of downloading and extracting the executable if it was needed, it didn’t take much work to add it into the script.

When invoked, the function creates an empty array named $sortedlist, then iterates through each location specified. If the location is valid and a key has been provided, a temporary list of child items in that location is created. For each item in that temporary list a new object is created containing the PowerShell Path, Name, and the TaskPath. Most of the objects being found here relate to scheduled tasks and will have a relative path for the task location within Task Scheduler. The exceptions are task folders themselves, hence using try-catch to assign a null taskpath where necessary.

	$sortedlist = $sortedlist | Sort-Object -Property TaskPath -Descending
	foreach ($k in $sortedlist) {
		if (($k.Name -like "*$($key)*") -or ($k.TaskPath -like "*$($key)*")) {
			Write-Host "Found registry entry matching the search key.`nName:`t$($k.Name)`nTaskPath:`t$($k.Taskpath)`nPSPath:`t$($k.PSPath)`n"
			"Found registry entry matching the search key.","Name:	$($k.name)","Taskpath:	$($k.Taskpath)","PSPath:	$($k.PSPath)","" | Out-file -Filepath $global:logfile -append				
			# The backtick serves to escape the quote marks which are to be included in the string. The backslash will serve as an escape character to preserve the quote marks in the string when it is passed to PSExec, so that spaces in the path do not cause problems.
			$command="Remove-Item -Path \`""+$($k.Name -replace "HKEY_LOCAL_MACHINE","HKLM:")+"\`" -Recurse -Force -Confirm:`$false"
			if ($psexec) {
				Write-Host "PsExec is available; command will be executed as the SYSTEM account."
				"PsExec is available; command will be executed as the SYSTEM account." | Out-file -Filepath $global:logfile -append
				$filepath=$psexec
				$pspath="$env:systemroot\system32\WindowsPowerShell\v1.0\powershell.exe"
				$arglist="-s /accepteula $pspath -InputFormat None -command $command"
				
				Write-Host "The command being executed is:`n$($psexec) $($arglist)"
				"The command being executed is:","$($psexec) $($arglist)" | Out-file -Filepath $global:logfile -append
				
				# Use Start-Process to run the command so that we can check the return code afterwards.
				$remkey=Start-Process -Filepath $filepath -ArgumentList $arglist -Wait -NoNewWindow -PassThru
					
				if ($remkey.Exitcode -eq "0") {
					Write-Host "Successfully deleted registry key $($k.PSPath)." -foregroundcolor green
					"Successfully deleted registry key $($k.PSPath)." | Out-file -Filepath $global:logfile -append
				} else {
					Write-Host "Failed to delete registry key $($k.PSPath)." -foregroundcolor red
					"Failed to delete registry key $($k.PSPath)." | Out-file -Filepath $global:logfile -append
				}
				Remove-Variable -Name command,filepath,pspath,arglist,remkey -Force -ErrorAction SilentlyContinue
				
			} else {
				try {
					Invoke-Expression $command -ErrorAction Stop
					Write-Host "Successfully deleted registry key $($k.PSPath)." -foregroundcolor green
					"Successfully deleted registry key $($k.PSPath)." | Out-file -Filepath $global:logfile -append
				} catch {
					Write-Host "Unable to delete registry key $($k.PSPath), error was:`n$($_.Exception.Message)" -foregroundcolor red
					"Unable to delete registry key $($k.PSPath), error was:","$($_.Exception.Message)" | Out-file -Filepath $global:logfile -append
				}
				Remove-Variable -Name command -Force -ErrorAction SilentlyContinue
			}
		}
	}
}

Having now iterated through all specified locations and identified the objects of interest, the $sortedlist array is piped through Sort-Object to sort its contents in descending order by the TaskPath value. This is not required, given that the Remove-Item cmdlet does support the Recurse parameter, but by doing so the script will log the details of each key which has been identified before attempting to delete them. In the unlikely event of a false positive this will help with remediation.

After sorting the list, each item is evaluated against the specified key in either the Name or TaskPath values. When a match is found the details are written to the logfile and a removal command is defined.

If the PsExec parameter has been specified when invoking the function, additional values are declared which are used in conjunction with Start-Process to run the removal command as the system account. Note the tuse of the “InputFormat -None” parameter when invoking powershell with PsExec – this is required for the process to terminate cleanly. Using the Wait parameter when running Start-Process means that the script waits for the new process to complete before proceeding further.

Once the process is complete, the Exitcode value is checked to determine whether the removal command executed successfully.

If the PsExec parameter has not been specified when invoking the function, the removal comand is executed using Invoke-Expression within a try-catch statement.

At the end of this function, step 12 on the list is complete.

Function Remove-ScheduledTasks {
    param(
		[string]$key,
        [string]$psexec
    )
    $alltasks=Schtasks.exe /query /fo table /NH | Where-Object {$_ -notlike ""}                   
    Write-Host "Retrieved list of all scheduled tasks and folders."
	"Retrieved list of all scheduled tasks and folders." | Out-file -Filepath $global:logfile -append

    $folderlist=@();
    foreach ($f in ($alltasks | Where-Object {($_ -match "^Folder:") -and ($_ -like "*$($key)*")})) {
        $folderlist+=[string]($f -replace ("Folder: ",""))
    }

	$folderlist=$folderlist | Sort-Object -Descending

    if ($folderlist.count -gt 0) {
		Write-Host "Found $($folderlist.count) folders matching the search key. `nFolders:";
		"Found $($folderlist.count) folders matching the search key.","Folders:" | Out-file -Filepath $global:logfile -append
		"Folders:" | Out-file -Filepath $global:logfile -append
		foreach ($f in $folderlist) {
			Write-Host $f;
			$f | Out-file -Filepath $global:logfile -append
		}
    }
	
    $foundtasks=@()
   
    foreach ($f in $folderlist) {
        [boolean]$found=$false
        foreach ($t in $alltasks) {
            if ($found) {
                if (($t -notmatch "^Folder: ") -and ($t -notmatch "^INFO: ")) {
                    $foundtasks+=[string]($f+"\"+(($t -split "  ")[0]).TrimEnd(" "))
                } else {
					break
                }
            }
            if ($($t -replace ("Folder: ","")) -eq $f) {
                $found=$true
            }
        }
    }
	
	$foundtasks = $foundtasks | Sort-Object -Descending

The last step in the list, number 13, is to remove the GWX-related scheduled tasks. While developing this script I found that the registry keys identified in step 12 mostly correlate to the scheduled tasks, but in the interests of being thorough and the possibility of new tasks being added in different locations, I decided to play it safe and assume that numerous scheduled tasks remain in place at this point.

This function takes two parameters, similarly to the Remove-RegistryKeys function – a key for matching against task names or folder locations, and the optional location of the PsExec executable.

Windows 8 & Server 2012 and beyond have native cmdlets for scheduled task handling, but those cmdlets are not available on Windows 7. To this end, all OSs use the schtasks.exe exectuable for task manipulation. Schtasks.exe uses switches to determine the mode in which it functions – so /query is essentially for listing existing tasks. The other parameters in the command specify that the output format will be tabular and that no headers should be supplied. By piping through Where-Object any empty lines can be removed, making subsequent matching tasks easier.

Next, an array of folders which match the specified search key is generated. Folders are easily identified in the output of schtasks as the line will start with “Folder: “, so a regular expression checking the start of line is enough. Once all relevant folders have been identified, they are written to the console and to the logfile. As with the registry keys, the $folderlist array is sorted in descending order. Unlike in the Remove-RegistryKeys function, here it is a requirement – schtasks will not allow the deletion of a scheduled task or folder which contains subtasks or subfolders, so by sorting the folders to be deleted in descending order the script ensures that tasks are deleted in the right order.

With the $folderlist array populated, a new array is declared for all relevant tasks. Iterating through the folders, a $found boolean is declared and set as false. Each line in in alltasks is checked; while $found is false, only folder names are checked.

When a line matching the folder name is found, the $found boolean is set to true – at this point, the full name for the task (including the folder) is constructed. In order to understand the requirement for the split operation, it’s easiest to consider a sample of the output of schtasks:

PS C:\Windows\system32> Schtasks.exe /query /fo table /NH | Where-Object {$_ -notlike ""}
Folder: \
Adobe Acrobat Update Task                N/A                    Unknown
Adobe Flash Player Updater               01/06/2016 13:39:00    Ready
AdobeAAMUpdater-1.0-Winfruity-Kyle       02/06/2016 02:00:00    Ready
CCleanerSkipUAC                          N/A                    Ready
GoogleUpdateTaskMachineCore              02/06/2016 08:45:00    Ready
GoogleUpdateTaskMachineUA                01/06/2016 12:45:00    Ready
User_Feed_Synchronization-{76A88F07-031B 15/05/2019 22:28:33    Ready
Folder: \Microsoft
INFO: There are no scheduled tasks presently available at your access level.

For each value, a TaskName, Next Run Time and Status value are returned. Since only the taskname is relevant here, the line is split. The split character is a set of 4 spaces – while the output sample above shows that only 1 space will reliably be present between the task name and the Next Run Time value, two spaces are required to avoid partial task names in cases where the name contains one or more non-sequential spaces. While testing the script I found that a minimum of 4 spaces were available to use as a split-string. To be on the safe side, I use the TrimEnd method to remove any trailing spaces from the task name. If I were to generalise a function for parsing task information from Schtasks.exe, I would most likely do so using logic which identified either “N/A” or a regular expression matching the date format used for Next Run Time.

Once a line is found which matches either a folder name or an Info statement, a break is used to exit the for loop as no further iteration is required for that folder.

After all task names have been identified and added to the $foundtasks array, the array itself is sorted in descending order, as with the

       if ($foundtasks.count -gt 0) {
		# Write a list of the tasks identified for deletion to the screen and the logfile.
        Write-Host "The following scheduled tasks were found:";
		"The following scheduled tasks were found:" | Out-file -Filepath $global:logfile -append
		foreach ($t in $foundtasks) {
			Write-Host $t
			$t | Out-file -Filepath $global:logfile -append
		}
		
		# Set a template for the schtasks.exe command to use.
		$template="schtasks.exe /Delete /S $env:computername /TN `"TASKNAME`" /F"
		
		foreach ($t in $foundtasks) {
			Write-Host "Proceeding with deletion of task $($g)..."
			"Proceeding with deletion of task $($g)..." | Out-file -Filepath $global:logfile -append							
			# Copy the template and adapt it for the task being deleted, then declare variables based on the availability of PsExec.
			$cmd=$template -replace "TASKNAME",$t
			if ($psexec) {
				$arglist="-s /accepteula $cmd"
				$filepath=$psexec
			} else {
				$arglist="/c $cmd"
				$filepath="$env:systemroot\system32\cmd.exe"
			}
			Write-Host "Executing command:`t$($cmd)"
			"Executing command:`t$($cmd)" | Out-file -Filepath $global:logfile -append

			# Use Start-Process to run the command so that we can check the return code afterwards.
			$remtask=Start-Process -Filepath $filepath -ArgumentList $arglist -Wait -NoNewWindow -PassThru
			
			if ($remtask.ExitCode -eq "0") {
				Write-Host "Successfully deleted scheduled task $($t)." -foregroundcolor green
				"Successfully deleted scheduled task $($t)." | Out-file -Filepath $global:logfile -append
			} else {
				Write-Host "Failed to delete scheduled task $($t)." -foregroundcolor red
				"Failed to delete scheduled task $($t)." | Out-file -Filepath $global:logfile -append
			}
			Remove-Variable -Name cmd,arglist,filepath,remtask -Force -ErrorAction SilentlyContinue
		}

Assuming tasks are found, the contents of the $foundtasks array are written to the display and the logfile.

Next, a template command for deleting a scheduled task on the local computer is defined. Note that in order to accept spaces in the task name, the template includes escaped quotes around the TASKNAME string.

With the template prepared, the script iterates through each entry in the $foundtasks array and for each task, creates a new command string based on the template.

Arguments are defined for a Start-Process command based on whether the PsExec executable has been specified as a parameter or not. As before Start-Process is run with the Wait switch to ensure that the process completes before the script proceeds. The outcome is evaluated by checking the ExitCode value for the removal task and the display and logfile are updated accordingly. After this, variables used within the loop are removed.

		
		foreach ($f in $folderlist) {
			$cmd=$template -replace "TASKNAME",$f
			if ($psexec) {
				$arglist="-s /accepteula $cmd"
				$filepath=$psexec
			} else {
				$arglist="/c $cmd"
				$filepath="$env:systemroot\system32\cmd.exe"
			}
			Write-Host "Executing command:`t$($cmd)"
			"Executing command:`t$($cmd)" | Out-file -Filepath $global:logfile -append
		
			$remfolder=Start-Process -Filepath $filepath -ArgumentList $arglist -Wait -NoNewWindow -PassThru
			if ($remfolder.Exitcode -eq "0") {
				Write-Host "Successfully deleted scheduled task folder $($f)." -foregroundcolor green
				"Successfully deleted scheduled task folder $($f)." | Out-file -Filepath $global:logfile -append
			} else {
				Write-Host "Failed to delete scheduled task folder $($f). Error was:`n$($_.Exception.Message)" -foregroundcolor red
				"Failed to delete scheduled task folder $($f). Error was:","$($_.Exception.Message)" | Out-file -Filepath $global:logfile -append
			}
			Remove-Variable -Name cmd,arglist,filepath,remfolder -Force -ErrorAction SilentlyContinue
		}
	} else {
        Write-Host "No relevant scheduled tasks were found.`n"
		"No relevant scheduled tasks were found." | Out-file -Filepath $global:logfile -append	
	}
}

The final section of this function is very similar to the previous section – it iterates through the folderlist and attempts to delete them.

One thing to note about this function is that the key-matching is done against the folder name. In practice while testing this, I did not run into any issues as all GWX scheduled tasks appear to be contained within a folder with the term GWX somewhere in the name. However, generalising this function would require a change to the task identification logic so that all tasks were checked against the key, instead of identifying folders of interest and enumerating all tasks within them.

The main script body

Having finished dissecting the functions used by the script, the last thing to do is review the remaining section of the main script body to see how the functions are called.

		# Restrict the folder so that TrustedInstaller cannot repair the changes.
		Restrict-Folder -directory "C:\Windows\system32\GWX" -group "NT SERVICE" -user "TrustedInstaller"

		# Retrieve PsExec to allow running certain actions as the System account to bypass permission issues 		
		Download-File -outfilepath $($scriptroot+"\PSTools.zip") -URI "https://download.sysinternals.com/files/PSTools.zip"
		Extract-Zip -file ($scriptroot+"\PSTools.zip") -location $($scriptroot+"\PSTools") -extractlist @("PsExec.exe") -cleanup $true

		# Identify each registry key involving a GWX component, and manually iterate through them to remove each one.

		Write-Host "Commencing registry modifications."
		"Commencing registry modifications." | Out-file -Filepath $global:logfile -append
		if (Test-Path ($scriptroot+"\PSTools\PsExec.exe")) {
			Remove-RegistryKeys -locations @("HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Schedule\TaskCache\Tasks","HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Schedule\TaskCache\Tree\Microsoft\Windows\Setup") -key "gwx" -psexec $($scriptroot+"\PSTools\PsExec.exe")
		} else {
			Remove-RegistryKeys -locations @("HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Schedule\TaskCache\Tasks","HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Schedule\TaskCache\Tree\Microsoft\Windows\Setup") -key "gwx"
		}
		Write-Host "Registry modifications completed."
		"Registry modifications completed." | Out-file -Filepath $global:logfile -append		

		# Identify related Task Scheduler tasks and delete them. Check if PsExec is available and modify the process invocation accordingly.
		if (Test-Path ($scriptroot+"\PSTools\PsExec.exe")) {
			Remove-ScheduledTasks -key "gwx" -psexec $($scriptroot+"\PSTools\PsExec.exe")
		} else {
			Remove-ScheduledTasks -key "gwx"
		}

		
		# Check if a post-reboot scheduled task has been requested for hiding updates, and if so invoke the relevant function.
		If ($hidetask -and $global:loaded) {
			Write-Host "Creating scheduled task for post-reboot automated hiding of GWX-related updates."
			"Creating scheduled task for post-reboot automated hiding of GWX-related updates." | Out-File -Filepath $global:logfile -append
			Schedule-HideUpdates -updatelist $kbarray -scriptroot $scriptroot
		}
		
		# Finished!
		if ($restart) {
			Write-Host "Script actions completed. A reboot is required to complete the uninstallation of one or more updates."
			"Script actions completed. A reboot is required to complete the uninstallation of one or more updates." | Out-file -Filepath $global:logfile -append
		} else {
			Write-Host "All script actions are now complete."
			"All script actions are now complete." | Out-file -Filepath $global:logfile -append
		}

First the Restrict-Folder function is invoked.

Next, Download-File and Extract-Zip are used to retrieve and extract the PsExec executable.

Once that’s complete, Remove-RegistryKeys is invoked – the paths to check and key to use are specified here, and an if-else statement is used to modify the invocation depending on whether PsExec.exe was obtained successfully.

The same approach is used when invoking Remove-ScheduledTasks.

Lastly, the $hidetask parameter and $global:loaded variable values are checked. If both are true, Schedule-HideUpdates is invoked to create a post-reboot task to attempt to hide the GWX-related updates from Windows Update. After this is done, a message is written to the screen and the log file based on whether a reboot is pending.

Given the sheer length of the script, I’ll follow up this post with another post containing just the script code, along with the (very simple) instructions for using it.

Disabling the “Get Windows 10” upgrade agent, part 2

In Part 1, I explained how I handled checking the requirements for the script to ensure that it could run correctly. In terms of the list of tasks for eliminating the GWX component, we’re still at Step 1. However, scrutiny of that list allows it to be further broken down as follows – the new additions are bolded:

  1. Verify that the current session is running with administrative privileges
  2. Install (if necessary) and load the PSWindowsUpdate module.
  3. Create a restore point
  4. Kill any running GWX-related processes.
  5. Uninstall KB3035583 (and any other GWX-related updates).
  6. Check if reboot is required and schedule hiding the updates.
  7. Configure policy for disabling OS upgrade display in the Windows Update interface.
  8. Take ownership of C:\Windows\System32\GWX, from TrustedInstaller
  9. Give Administrators group full control
  10. Rename the GWX folder to something else
  11. Replace it with an empty folder named GWX and deny access to TrustedInstaller
  12. Take a full backup of the whole registry now, to be paranoid, regedit again, find the GWX registry keys and delete them
  13. Delete all the GWX scheduled tasks.

Based on this list, steps 1 & 2 are complete and we’re ready to get started on step 3.

Function Create-Checkpoint {
    try {
        Checkpoint-Computer -Description "Disable-GWX - Start of script." -RestorePointType APPLICATION_UNINSTALL -ErrorAction Stop
        Write-Host "Checkpoint restore point created successfully." -foregroundcolor green
		"Checkpoint restore point created successfully." | Out-file -Filepath $global:logfile -append
        $global:checkpoint=$true
    } catch {
        Write-Host "Unable to create checkpoint restore point; proceeding may be dangerous." -foregroundcolor red
		"Unable to create checkpoint restore point; proceeding may be dangerous." | Out-file -Filepath $global:logfile -append
        $choice=Read-Host("Continue anyway? Y/N")
        if (($choice -eq "Y") -or ($choice -eq "y")) {
            Write-Host "Bypassing checkpoint restorepoint requirement to proceed. You have been warned." -foregroundcolor yellow
			"You have chosen to bypass checkpoint restorepoint requirement to proceed. You have been warned." | Out-file -Filepath $global:logfile -append
            $global:checkpoint=$true
        } else {
            $global:checkpoint=$false
        }
    }
}

PowerShell already has a built-in cmdlet for creating restore points, but I wrapped it in a function to add logging and a boolean variable to track whether the restore point is created successfully. I also decided it might be useful to allow the operator to continue with the process even if the checkpoint process fails.

Function Schedule-HideUpdates {
	param(
		[array]$updatelist,
		[string]$scriptroot
	)
	# Export the array of updates to be hidden to a text file.
    $updatelist | Out-file -Filepath $($scriptroot+"\kbarray.txt")
	# Create a here-string containing the script to be executed post-rebooot to hide the required updates.
$taskscript=@"
`$global:logfile=$global:logfile
Write-Host "Disable-GWX post-reboot task: Attempting to hide GWX-related updates in Windows Update..." -foregroundcolor white
(Get-Date -format "yyyy-mm-dd_HH:mm")+": Started post-reboot task to hide GWX-related updates in Windows Update." | Out-File -Filepath `$global:logfile -append
ipmo PSWindowsUpdate
`$kbarray=Get-Content $($scriptroot+"\kbarray.txt")
[int]`$success=0
[int]`$failure=0
foreach (`$kbnumber in `$kbarray) {
	try {
		Hide-WUUpdate -KBArticleID `$kbnumber -Confirm:`$false -ErrorAction Stop
		Write-Host "Update KB`$kbnumber should now be hidden." -foregroundcolor green
		"Update KB`$kbnumber should now be hidden." | Out-file -Filepath `$global:logfile -append
		`$success++
	} catch {
		Write-Host "An error occured while hiding update KB`$kbnumber; error message was:``n`$_.Exception.Message" -foregroundcolor red
		"An error occured while hiding update KB`$kbnumber; error message was","+`$_.Exception.Message" | Out-file -Filepath `$global:logfile -append
		`$failure++
	}
}
# Remove scheduled task so that it doesn't run again.
`$deletetask='schtasks /delete /tn "Hide GWX Updates" /f'
try {
	Invoke-Expression `$deletetask
	Write-Host "Successfully deleted scheduled task for hiding GWX-related updates." -foregroundcolor green
	"Successfully deleted scheduled task for hiding GWX-related updates." | Out-File -Filepath `$global:logfile -append
} catch {
	Write-Host "Unable to delete scheduled task for hiding GWX-related updates, error was:`n`$_.Exception.Message" -foregroundcolor green
	"Unable to delete scheduled task for hiding GWX-related updates, error was:",`$_.Exception.Message | Out-File -Filepath `$global:logfile -append
}
Write-Host "All actions have been completed:`nUpdates successfully hidden:`t$success`nUpdates which were not hidden:`t$failure"
Read-host=("Press Return to finish.")
"@
	# Output the script to a file in the script root location.
	$scriptfile=$scriptroot+"\HideGWXUpdates.ps1"
	$taskscript | Out-file -Filepath $scriptfile
	
	# Create a new scheduled task as the current user
	Write-Host "Creating scheduled task to run at next logon..."
	$password=Read-Host -Prompt "Please enter the password for $($env:username)" -AsSecureString
	
	$createtask='schtasks /create /tn "Hide GWX Updates" /tr "C:\windows\system32\WindowsPowerShell\v1.0\powershell.exe -File $scriptfile" /sc onlogon /ru $env:username /rp $($password | ConvertFrom-SecureString)'
	try{
		Invoke-Expression $createtask
		Remove-Variable $createtask -Force
		Write-Host "Successfully created scheduled task for hiding GWX-related updates."
		"Successfully created scheduled task for hiding GWX-related updates." | Out-File -Filepath $global:logfile -append
	} catch {
		Remove-Variable $createtask -Force
		Write-Host "Failed to create scheduled task for hiding GWX-related updates, error was:`n$_.Exception.Message"
		"Failed to create scheduled task for hiding GWX-related updates, error was:",$_.Exception.Message | Out-File -Filepath $global:logfile -append	
	}
}

The basic functionality for hiding Windows Updates is provided by the PSWindowsUpdate module. However, if an update requires a reboot to complete its uninstallation, it will not be listed as an available update (and thus be available for hiding) until the reboot is complete. To work around this, I wrote a function that accepts a list of updates and a filepath as parameters, and uses them to create a scheduled task which iterates through the list and attempts to hide each update in turn, then remove itself. This function in particular could be quite easily repurposed – for example, the actions specified in the script generated by the function could be parametrised rather than statically defined.

The first action in the function is to export the list of updates to be hidden (stored in an array whose name is supplied as a parameter) to a text file.

The next action is to start a here-string which will contain the script to be run by the scheduled task. As always with here-strings, the opening and closing lines have to be at the start of their respective lines. The first line of the here-string defines the path for the $global:logfile variable to be the same as that used in the main script, so that the scheduled task action outcomes will be appended to the main script log. Note the use of backticks (`) to differentiate between variables that will be evaluated within the here-string and variable names being declared. Next, the PSWindowsUpdate module is loaded, and the text file with the list of updates is read into an array. The script then iterates through the array attempting to hide each update in turn, using a try-catch statement to capture any errors. When all updates have been processed, an expression is defined to use schtasks.exe to delete the scheduled task. Lastly, a success/failure count is written to the output and a Read-Host prompt is used to keep the script on-screen until it is acknowledged by the user. When the here-string declaration is complete, it is written to a file in the $scriptroot filepath with the name HideGWXUpdates.ps1.

As the main script requires administrative permissions to run, the next step is to request the password for the administrator account under which the script is running. This is done using the AsSecureString parameter to ensure that passwords are not stored in plaintext unnecessarily. An expression is then defined to create the scheduled task, with the task trigger being specifid as the next logon of any user on the system, the task action being to open a PowerShell session and run the HideGWXUpdates.ps1 script. The password is decrypted by piping it through the ConvertFrom-SecureString cmdlet. The expression is executed using Invoke-Expression, and the $createtask variable is then removed. This action is run within a try-catch statement so that the outcome and any errors can be captured in
the log file.

Between the two functions above, step 3 and (most of) Step 6. Steps 3-7 are addressed in full in the remainder of this post via sections of the main script body, as follows:

    # Attempt to create restore point.
	Create-Checkpoint
    # Use status of "startcheckpoint" to determine whether to proceed or not.
    if ($global:checkpoint) {
		{...}
	} else {
		Write-Host "Unable to create initial restore point; no changes will be made." -foregroundcolor yellow
		"Unable to create initial restore point; no changes will be made." | Out-file -Filepath $global:logfile -append	
	}

To start with, the Create-Checkpoint function is invoked, and as shown above declares a global boolean variable depending on its success or failure. An if-else test is used to check the boolean variable and determine whether to proceed with any subsequent actions or not – hence the option in the Create-Checkpoint function to forcibly set the value of the boolean to “true” in the event of a checkpoint failure. This covers step 3.

# Check for and kill running GWX processes
$proclist=(Get-Process | Where-Object {$_.ProcessName -like "*GWX*"})
if ($proclist) {
	foreach ($p in $proclist) {
		try {
			Stop-Process $p.Id -Force -ErrorAction Stop
			Write-Host "Successfully terminated process $($p.Id)" -foregroundcolor green
			"Successfully terminated process $($p.Id)" | Out-file -Filepath $global:logfile -append
		} catch {
			Write-Host "Unable to stop process $($p.Id), error was:`n$($_.Exception.Message)" -foregroundcolor red
			"Unable to stop process $($p.Id), error was:`n$($_.Exception.Message)" | Out-file -Filepath $global:logfile -append
		}
	}
}

Next, Get-Process is piped through a Where-Object filter matching the ProcessName attribute to the “*GWX*” string fragment, and the results are stored in an array. If the array contains any values, the script iterates through them and uses Stop-Process with the Force parameter to attempt to terminate the process, again using try-catch statements to capture any errors for logging purposes. This covers step 4.

# Check for GWX-related updates, uninstall if necessary
$kbarray=("3068708","3022345","2952664","2976978","2977759","3075249",""3080149","3035583")
foreach ($kbnumber in $kbarray) {
	$present=Get-WMIObject -Class Win32_QuickFixEngineering | Where-Object {$_.HotFixID -match "^KB$($kbnumber)*"}
	if ($present) {
		$restart=$true
		Write-host "Found KB$($kbnumber) on system, attempting uninstallation..." -foregroundcolor yellow
		"Found KB$($kbnumber) on system, attempting uninstallation..." | Out-file -Filepath $global:logfile -append
		try {
			# Uninstall the update, supressing reboot.
			wusa.exe /uninstall /kb:$kbnumber /quiet /log /norestart
			Write-Host "Uninstalled update KB$($kbnumber) successfully." -foregroundcolor green
			"Uninstalled update KB$($kbnumber) successfully. A reboot will be required to complete the uninstallation process." | Out-file -Filepath $global:logfile -append
		} catch {
			Write-Host "Unable to remove update KB$($kbnumber), error was:`n$($_.Exception.Message)" -foregroundcolor red
			"Unable to remove update KB$($kbnumber), error was:","$($_.Exception.Message)" | Out-file -Filepath $global:logfile -append
		}
	} else {
		Write-Host "KB$($kbnumber) is not installed." -foregroundcolor green
		"KB$($kbnumber) is not installed." | Out-file -Filepath $global:logfile -append
	}
}

This section declares an array of update identifiers for updates related to GWX, then iterates through the array using Get-WMIObject to check the win32_QuickFixEngineering class and the HotFixID Attribute to determine whether each update is installed. An if-else statement is used to determine whether action is required, and the relevant details are written to the logfile. The list of updates in this section of the code is based on this post.

As a precaution, the script assumes that a reboot is required if any of the updates are present, though it could be refined to use the PSWindowsUpdate module and check if a reboot is required for each update that is found ot be installed on the system. For each update which is installed, wusa.exe is used to attempt a silent uninstallation of the update, with the norestart parameter specified to prevent an automatic reboot.

This covers step 5.

# Check if the PSWindowsUpdate module is available and if so use the Hide-WUUpdate command to hide all updates in $kbarray.
if ($global:loaded) {
	if (!$restart) {
		# No updates were uninstalled, so no reboot is required. 
		foreach ($kbnumber in $kbarray) {
			try {
					Hide-WUUpdate -KBArticleID $kbnumber -Confirm:$false -ErrorAction Stop
					Write-Host "Update KB$($kbnumber) should now be hidden." -foregroundcolor green
					"Update KB$($kbnumber) should now be hidden." | Out-file -Filepath $global:logfile -append
			} catch {
				Write-Host "An error occured while hiding update KB$($kbnumber); error message was:`n$($_.Exception.Message)" -foregroundcolor red
				"An error occured while hiding update KB$($kbnumber); error message was","$($_.Exception.Message)" | Out-file -Filepath $global:logfile -append
			}
		}
	} else {
		# One or more updates were uninstalled so a reboot is required. Thus a scheduled task is required to try and hide the updates in kbarray on the next boot.
		Schedule-HideUpdates -updatelist $kbarray -scriptroot $scriptroot
	}
} else {
	Write-Host "PSWindowsUpdate module is not loaded; you will need to manually hide the following GWX-related updates to prevent them from being (re)installed.`n$($kbarray)" -foregroundcolor yellow
	"PSWindowsUpdate module is not loaded; the following updates will need to be hidden manually to prevent them from being (re)installed.","$($kbarray)" | Out-file -Filepath $global:logfile -append
}

This section uses the state of the $global:loaded boolean variable (declared previously in the main script body, as described in the previous post) to check if the PSWindowsUpdate module has been loaded. The $restart boolean is then checked to determine whether updates can be hidden immediately or not. If no reboot is required, the script iterates through each update in the array and invokes the Hide-WUUpdate cmdlet. If a reboot is required, the Schedule-HideUpdates function is invoked. At the end of this script block, Step 6 is complete.

# Configure policy for preventing the display of the update via the Windows Update interface.
$exists=get-Item HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate -ErrorAction SilentlyContinue
if ($exists) {
	if ((Get-ItemProperty HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate).DisableOSUpgrade -eq "1") {
		# Key exists and has correct value, write confirmation to screen
		Write-Host "Policy to disable delivery of Windows 10 through the Windows Update interface is already configured." -foregroundcolor green    
		"Policy to disable delivery of Windows 10 through the Windows Update interface is already configured." | Out-file -Filepath $global:logfile -append
	} else {
		# Key exists with incorrect value.
		try {
			Set-ItemProperty -Path "HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate" -Name "DisableOSUpgrade" -Value 1 -Force -Confirm:$false
			Write-Host "Configured existing registry key to enable policy disabling Windows 10 delivery through the Windows Update interface." -foregroundcolor yellow
			"Configured existing registry key to enable policy disabling Windows 10 delivery through the Windows Update interface." | Out-file -Filepath $global:logfile -append
		} catch {
			Write-Host "An error occured while setting an existing registry key to enable policy disabling Windows 10 delivery through the Windows Update interface; the error message was:`n$($_.Exception.Message)" -foregroundcolor yellow.
			"An error occured while setting an existing registry key to enable policy disabling Windows 10 delivery through the Windows Update interface. The error message was:","$($_.Exception.Message)" | Out-file -Filepath $global:logfile -append
		}
	}
} else {
	# Key doesn't exist.
	try {
		New-Item -Path "HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate"
		New-ItemProperty -Path "HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate" -Name "DisableOSUpgrade" -Value 1
		Write-Host "Created new registry key & property to enable policy disabling Windows 10 delivery through the Windows Update interface." -foregroundcolor yellow.
		"Created new registry key to enable policy disabling Windows 10 delivery through the Windows Update interface." | Out-file -Filepath $global:logfile -append
	} catch {
		Write-Host "Error creating new registry key & property to enable policy disabling Windows 10 delivery through the Windows Update interface, error was:`n$($_.Exception.Message)" -foregroundcolor yellow.
		"Error creating new registry key to enable policy disabling Windows 10 delivery through the Windows Update interface. Error was: ", "$($_.Exception.Message)" | Out-file -Filepath $global:logfile -append
	}
}

This final code block addresses Step 7, checking whether the registry keys governing Windows Update and the Windows 10 OS upgrade settings exist. If the keys exist, the relevant values are checked and updated if necessary; if not, they are created.

At this point the script is just over half-way through the list of steps required; the remaining steps will be covered in Part 3.

Disabling the “Get Windows 10” upgrade agent, part 1

I haven’t posted any updates recently because my opportunities to experiment with PowerShell have been reduced in the last few months, having moved into a new job far less Windows-based scripting.

I’ve been keeping my hand in working on a script designed to block the Get-Windows10 utility that has been a source of frustration for many Windows 7 & 8 users. I’m well aware that the peak time for such a script to be useful is past, but I thought that it would be a good exercise. More importantly, while there are many utilities out there written by (what I hope are) well-intentioned individuals which can achieve the same end result, the ones I have seen have not released their source code so you have to take them at their word that nothing else is being done by the binary they ask you to run with elevated permissions. Whereas using a script might be marginally more work, but allows more visibility and understanding of what’s being changed.

It’s turned out to be quite long, so I figured I’d split it across two posts.

To start with, I’ll post the list of steps that I’m using as the basis for the script – this was supplied by a former colleague and friend had been frustrated dealing with multiple instances of GWX and wanted to share his method for eradicating it:

  1. Uninstall KB3035583
  2. Take ownership of C:\Windows\System32\GWX, from TrustedInstaller: right-click on GWX folder, Properties->Security->Advanced->Owner->Edit->UAC->(choose owner account)->(tick Replace owner on subcontainers and objects->OK
  3. Give Administrators group full control: right-click on GWX folder, Properties->Security->(choose group)->Edit->(tick Full control in the Allow column)->OK
  4. Move it: right-click on GWX folder, Rename->UAC->(call it GWX_bye or whatever)
  5. Replace with null: Windows Explorer, right-click->New Folder, call it GWX and deny access to TrustedInstaller via process (3).
  6. Take a full backup of the whole registry now, to be paranoid. Then regedit again, find the GWX registry keys, for each one right click->Copy Key Name then right click->Export to back them up, then right click->Delete — there are 11 in HKLM/SOFTWARE/Microsoft/Windows NT/CurrentVersion/Schedule/TaskCache/Tasks with random identifiers, and 11 more under HKLM/SOFTWARE/Microsoft/Windows NT/CurrentVersion/Schedule/TaskCache/Tree/Microsoft/Windows/Setup which correspond to the scheduled tasks in (7).
  7. Task Scheduler->UAC and look under Task Schedule Library->Microsoft->Windows->Setup and there are folders gwx and GWXTriggers with scheduled tasks in, matching the structure under Tree in the registry. If you do (6) it will whinge. Delete the gwx and GWXTriggers tasks.

Looking at that list of steps, the variety of actions required should explain why the script needs to be reasonably lengthy. In the interests of sanity, I’ve split each discrete task into its own function for ease of reusability.

An additional requirement for the script is that it has to work across Windows 7 and Windows 8.1. Windows 7 ships with PowerShell 2.0 by default, and overall I decided it would be more effective to try and do everything using only PowerShell 2.0 cmdlets, rather than solving the problem twice.

Function Check-AdministratorAccess {
    if (([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] "Administrator")) {
        $global:admin=$true
        Write-Host "Script is running with required privileges."
        "Script is running with required privileges." | Out-file -Filepath $global:logfile -append
    } else {
        $global:admin=$false
        Write-Host "Not running with required privileges! Open a new PowerShell instance with administrator privileges and try again." -foregroundcolor red
        "Not running with required privileges! Open a new PowerShell instance with administrator privileges and try again." | Out-file -Filepath $global:logfile -append
    }
}

Function Check-OS {
    $OS=(Get-WMIObject -class Win32_OperatingSystem -property Name).Name
    switch ($OS) {
        {$_ -like "*Windows 7*"} {
            $OSVersion="7"
            Write-host "Detected OS: Windows 7..."
            "Detected OS: Windows 7..." | Out-file -Filepath $global:logfile -append
            break
        }
        {$_ -like "*Windows 8*"} {
            $OSVersion="8"
            Write-host "Detected OS: Windows 8/8.1..."
            "Detected OS: Windows 8/8.1..." | Out-file -Filepath $global:logfile -append
            break
        }
        default {
            $OSVersion="Invalid"
            Write-Host "Invalid OS detected..." -foregroundcolor red
            "Invalid OS detected..." | Out-file -Filepath $global:logfile -append
            break     
        }
    }
}

Before anything else, the script needs to be running with administrator rights on an installation of Windows 7 or Windows 8. So step 0 is to ensure both of these requirements are met. First, administrator rights. Searching for this question shows a [i]lot[/i] of posts and forum threads, so it’s a pretty common question. I won’t pretend that I figured it out for myself – I nabbed the central logic for this function from [url=https://blogs.technet.microsoft.com/heyscriptingguy/2011/05/11/check-for-admin-credentials-in-a-powershell-script/]this Hey, Scripting Guy![/url] post.

In terms of identifying the OS, we don’t actually need the OS name or version outside of a single check that it is one of the versions we consider valid. So a simple like-match against the string returned by a WMI query against the OperatingSystem class is enough for our purposes.

Now that the question of requirements is addressed, we can start on the list of tasks.

Step 1 in the list is actually a good bit more complicated than it looks, in terms of PowerShell. Thus far PowerShell has no native utilities for managing Windows Updates, but Michal Gadja has written the very useful PSWindowsUpdate module that enables this. Which means:
* download the Zip file of the module from the Technet Gallery
* extract the Zip file
* install/load the extracted module (and incidentally patch the module file to address an issue on PS2.0 systems)
* use the relevant module to uninstall KB3035583

As it happens, this translates into three functions I’ve unimaginatively named Get-UpdateModule, Extract-Zip and Install-UpdateModule.

Function Get-UpdateModule {
	param(
		[string]$outfilepath,
		[string]$URI
	)
	if ($URI -and $outfilepath) {
		# Check PS Major Version proceed accordingly
				   
		if ($PSVersionTable.PSVersion.Major -gt 2) {
			Write-Host "PowerShell version 3 or higher found: Attempting to download PSWindowsUpdate module..."
			"PowerShell version 3 or higher found: Attempting to download PSWindowsUpdate module..." | Out-file -Filepath $global:logfile -append
			try {
				Invoke-Webrequest -URI $URI -OutFile $outfilepath -ErrorAction Stop
				Write-Host "Successfully downloaded PSWindowsUpdate media." -foregroundcolor green
				"Successfully downloaded PSWindowsUpdate media." | Out-file -Filepath $global:logfile -append
			} catch {
				Write-Host "Unable to download PSWindowsUpdate media, error was:`n$($_.Exception.Message)." -foregroundcolor red
				"Unable to download PSWindowsUpdate media, error was:`",$_.Exception.Message | Out-file -Filepath $global:logfile -append
			}
		} else {
			if (Get-Module BITSTransfer -ListAvailable) {
				try {
					ipmo BITSTransfer
					Write-Host "Loaded BITSTransfer module successfully." -foregroundcolor green
					"Loaded BITSTransfer module successfully." | Out-file -Filepath $global:logfile -append
				} catch {
					Write-Host "Unable to load BITSTransfer module for file download." -foregroundcolor red
					"Unable to load BITSTransfer module for file download." | Out-file -Filepath $global:logfile -append
				}
				if (get-Module BITSTransfer) {
					try {
						Start-BITSTransfer -source $URI -Destination $outfilepath -ErrorAction Stop
						Write-Host "Successfully downloaded PSWindowsUpdate media." -foregroundcolor green
						"Successfully downloaded PSWindowsUpdate media." | Out-file -Filepath $global:logfile -append
					} catch {
						Write-Host "Unable to download PSWindowsUpdate media, error was:`n$($_.Exception.Message)." -foregroundcolor red
						"Unable to download PSWindowsUpdate media, error was:",$_.Exception.Message | Out-file -Filepath $global:logfile -append
					}
				}
			} else {
				Write-Host "Unable to download PSWindowsUpdate media, no suitable cmdlets or modules are available." -foregroundcolor red
				"Unable to download PSWindowsUpdate media, no suitable cmdlets or modules are available." | Out-file -Filepath $global:logfile -append
			}
		}
	} else {
		Write-Host "URI or outfilepath value missing, please try again!" -foregroundcolor red
		"URI or outfilepath value missing, please try again!" | Out-file -Filepath $global:logfile -append
	}
}

The key to this is the realisation that downloads on the Technet Gallery have static download links, which means that instead of having to tweak one of my previous scripts to scrape the links out of the Gallery page I can just hardcode the relevant link into the main body of the script and pass it as a parameter into the function. The output file location is also passed as a parameter.

Since this was the first thing I started doing for this script, I ended up using a version check to see if version 3 or higher of PowerShell was installed, on the basis that this makes Invoke-WebRequest available. It’s not strictly required, but using a single core cmdlet is more efficient than having to load the BITSTransfer module, so I left it this way – aside from anything else, it’s a useful illustration that there are several ways of downloading files with PowerShell natively, depending on the version available.

# A generalised function for extracting compressed files to a specified location.
Function Extract-Zip {
    param(
        [string]$file,
        [string]$location
    )
    if (!(Test-Path $location)) {
        try {
            mkdir $location | out-Null
        } catch {
            Write-Host "Unable to create folder $location, error was:`n$($_.Exception.Message)" -foregroundcolor red
			"Unable to create folder $location, error was:`n$($_.Exception.Message)" | Out-file -Filepath $global:logfile -append
        }
    }
    if ((Test-Path $file) -and (Test-Path $location)) {
        # Instantiate a new shell object and namespace
        $shell=New-Object -com Shell.Application
        $zip=$shell.NameSpace($file)
        # Iterate through each item in the zip file and copy it to the specified location.
        try {
            foreach ($item in $zip.items()) {
                $shell.Namespace($location).Copyhere($item)
            }
            Write-Host "Finished extracting contents of $file to $location." -foregroundcolor green
			"Finished extracting contents of $file to $location." | Out-file -Filepath $global:logfile -append
        } catch {
            Write-Host "An error occured while extracting the contents of $file to $location; the error message was:`n$($_.Exception.Message)" -foregroundcolor red
			"An error occured while extracting the contents of $file to $location; the error message was:", "`n", "$($_.Exception.Message)" | Out-file -Filepath $global:logfile -append
        }
    } else {
        Write-Host "Unable to proceed with extraction, invalid input specified!" -foregroundcolor red
		"Unable to proceed with extraction, invalid input specified!" | Out-file -Filepath $global:logfile -append
        if (!(Test-Path $file)) {
            Write-Host "Could not find file $file!" -foregroundcolor red
			"Could not find file $file!" | Out-file -Filepath $global:logfile -append
			
        }
        if (!(Test-Path $location)) {
            Write-Host "Could not find or create folder path $location!" -foregroundcolor red
			"Could not find or create folder path $location!" | Out-file -Filepath $global:logfile -append
        }
    }
}

I would be lying if I said I came up with this myself – this function is really just an expanded version of the [url=http://www.howtogeek.com/tips/how-to-extract-zip-files-using-powershell/]Expand-ZIPFile function[/url] someone wrote at HowToGeek. I may return to it at some point and expand it to handle creating as well as extracting zip files; it might also be useful to expand it to use 7-Zip to support additional functions and file formats.

Function Install-UpdateModule {
	param(
		[string]$source,
		[string]$ModuleName
	)
	Write-Host "Installing $($ModuleName) module." -foregroundcolor green
	"Installing $($ModuleName) module." | Out-file -Filepath $global:logfile -append
	$target="C:\Windows\system32\WindowsPowershell\v1.0\Modules\"+$ModuleName
	$exists=Test-Path $target
	if (!$exists) {
		Write-Host "Target directory doesn't exist, attempting to create..." 
		"Target directory doesn't exist, attempting to create..." | Out-file -Filepath $global:logfile -append
		try {
			New-Item -Path "C:\Windows\system32\WindowsPowershell\v1.0\Modules" -name $ModuleName -ItemType Directory -ErrorAction Stop | Out-Null
			Write-Host "Target directory created." -foregroundcolor green
			"Target directory created." | Out-file -Filepath $global:logfile -append
		} catch {
			Write-Host "Couldn't create target directory." -foregroundcolor red
			"Couldn't create target directory." | Out-file -Filepath $global:logfile -append
		}
	} else {
		Write-Host "Target directory already exists."
		"Target directory already exists." | Out-file -Filepath $global:logfile -append
	}
	if (Test-Path $target) {
		foreach ($f in (GCI $source -recurse)) {
			$fileexists=Test-Path ($target+"\"+$f.Name)
			if (!$fileexists) {
				if ($f.Name -like "*.psm1" -and $PSVersionTable.PSVersion.Major -lt 3) {
					"Get-ChildItem -Path `$PSScriptRoot\*.ps1 | Foreach-Object { . `$_.FullName}" | Out-File -FilePath $f.FullName
				}
				try {
					Copy-Item $f.FullName -Destination $target -Force -Confirm:$false | Out-Null
					Write-Host "Copied file $($f.name) to installation directory." -foregroundcolor green
					"Copied file $($f.name) to installation directory." | Out-file -Filepath $global:logfile -append
				} catch {
					Write-Host "Unable to copy file $($f.name) to target directory." -foregroundcolor red
					"Unable to copy file $($f.name) to target directory." | Out-file -Filepath $global:logfile -append
				}
			} else {
				Write-Host "File $($f.name) already exists in target location." -foregroundcolor green
				"File $($f.name) already exists in target location." | Out-file -Filepath $global:logfile -append
			}
		}
	} else {
		Write-Host "Unable to install module, target directory could not be found or created." -foregroundcolor red
		"Unable to install module, target directory could not be found or created." | Out-file -Filepath $global:logfile -append
	}
}

For the most part, a fairly straightforward function and easily re-used. All this function does is take a source and module name as strings, then attempt to copy all files from the source to a sub-directory (named for the module) of the default Modules directory for the host system. The only specific function is the section that matches against the psm1 file extension – and that’s to ensure PowerShell 2.0 compatibility. The module file for this module includes a line that uses the Unblock-File cmdlet, which is native to PowerShell 3.0 but will trigger an error when invoked under PowerShell 2.0. Thus my workaround is to manually populate the content of that file rather than copy the version included in the download.

For context, the relevant section of the script that uses all these functions looks like this so far:

# Main script body
$scriptroot=Split-Path -parent $MyInvocation.MyCommand.Definition
$global:logfile=$scriptroot+"\"+(Get-Date -format 'yyyy_MM_dd_HHmm')+"_Disable-GWX.log"
cls
Write-Host "(Get-Date -Format 'yyyy-MM-dd HH:mm'): Disable-GWX"
(Get-Date -Format 'yyyy-MM-dd HH:mm')+": Disable-GWX script started..." | Out-file -Filepath $global:logfile -append
Write-Host "PowerShell version: $($PSVersionTable.PSVersion.Major)"
Check-AdministratorAccess
Check-OS

if ($global:admin -and ($OS -notlike "Invalid")) {
    # Check if module is installed and download it if not.
    $found=Get-Module -ListAvailable | Where-Object {$_.Name -like "PSWindowsUpdate"}
    if (!$found) {
        Write-Host "PSWindowsUpdate Module is not currently installed." -foregroundcolor yellow
        "PSWindowsUpdate Module is not currently installed." | Out-file -Filepath $global:logfile -append
		# Invoke Get-UpdateModule to retrieve module files from Technet.
		Get-UpdateModule -outfilepath $($scriptroot+"\PSWindowsUpdate.zip") -URI "https://gallery.technet.microsoft.com/scriptcenter/2d191bcd-3308-4edd-9de2-88dff796b0bc/file/41459/43/PSWindowsUpdate.zip"
        # Extract module files.
        Extract-Zip -file ($scriptroot+"\PSWindowsUpdate.zip") -location $scriptroot
		# Install the module files.
		Install-UpdateModule -source ($scriptroot+"\PSWindowsUpdate") -ModuleName "PSWindowsUpdate"
		# Test loading the installed module.
		try {
            Import-Module -Name PSWindowsUpdate
            Write-Host "Successfully loaded PSWindowsUpdate module." -foregroundcolor green
            "Successfully loaded PSWindowsUpdate module." | Out-file -Filepath $global:logfile -append
        } catch {
            Write-Host "Unable to load PSWindowsUpdate module, error was:`n$($_.Exception.Message)" -foregroundcolor red
            "Unable to load PSWindowsUpdate module, error was:" | Out-file -Filepath $global:logfile -append
            "$($_.Exception.Message)" | Out-file -Filepath $global:logfile -append
        }
    } else {
        # Module is already installed; attempt to load it.
        try {
            Import-Module -Name PSWindowsUpdate
			$global:loaded=$true
            Write-Host "Successfully loaded PSWindowsUpdate module." -foregroundcolor green
            "Successfully load PSWindowsUpdate module." | Out-file -Filepath $global:logfile -append
        } catch {
			$global:loaded=$false
            Write-Host "Unable to load PSWindowsUpdate module, error was:`n$($_.Exception.Message)" -foregroundcolor red
            "Unable to load PSWindowsUpdate module, error was:" | Out-file -Filepath $global:logfile -append
            "$($_.Exception.Message)" | Out-file -Filepath $global:logfile -append
        }       
    }
	<...>
} else {
    Write-Host "Script is not running with administrator privileges.`nPlease launch a new administrator session and relaunch the script." -foregroundcolor yellow
    "Script does not have required privileges to proceed. Terminating." | Out-file -Filepath $global:logfile -append
}

The admin and OS checks are both tested at the very start. Next, the available modules on the host system are checked; if it is already installed, an attempt is made to load it. If not, the process of downloading, extracting and installing it is started; after which an attempt is made to load it.

I’m going to leave it at that for now, but I’ll have more on this in a week or so.

Creating a new forwarding mailbox automatically

It’s been nearly a month since my last post, for boring real-world reasons like going on holiday. So let’s get to it. Here’s a script I wrote for Exchange 2010 to automate the in-house process we use for email forwarding when people go on long-term absences or leave the organisation.

The first thing is importing my New-Password script, because it’ll be used within the script to generate a strong password as part of the mailbox creation process.

# Import New-Password script
$scriptroot=(Split-Path -Parent -Path $MyInvocation.MyCommand.Definition)
$scriptpath=$scriptroot+"\New-Password.Ps1"
. "$scriptpath"

Everything else is encapsulated within a function, with parametrised string inputs. This could quite easily be modified to run using an input file, but since the process we use is currently done on a case-by-case basis that seems like an unnecessary change with limited value. In this case, “caseref” means a string identifying the request in our ticketing system.

   
Function New-ForwardingMailbox {
    param (
        [string]$mbxsmtp,
        [string]$fwdsmtp,
        [string]$caseref
    )

We start by defining and writing to a log file, then some basic input verification. Note that the verification process for the destination address requires it to be a local mailbox – this is by design because our organisational policies don’t allow auto-forwarding of all mail to an external service.

$logfile=<path>+(Get-Date -format yyyyMMdd-HHmm)+"_"+$caseref+"_"+$mbxsmtp+"_Create_forwarding_mailbox.log"
(Get-Date -format 'yyyy-MM-dd HH:mm')+": Starting new forwarding mailbox setup process..." | Out-File -filepath $logfile -append

# Sanity-check inputs: $mbxsmtp, $fwdsmtp, $caseref
try {
    $mbx=Get-Mailbox $mbxsmtp -Erroraction Stop
    Write-Host "Source mailbox exists." -foregroundcolor green
    "Source mailbox exists." | Out-File -filepath $logfile -append
} catch {
    $mbx=$null
    Write-Host "Source mailbox with address $($mbxsmtp) does not exist." -foregroundcolor red
    "Source mailbox with address $($mbxsmtp) does not exist." | Out-File -filepath $logfile -append       
}
try {
    $fwd=Get-Mailbox $fwdsmtp -Erroraction Stop
    Write-Host "Destination mailbox exists." -foregroundcolor green
    "Destination mailbox exists." | Out-File -filepath $logfile -append
} catch {
    $fwd=$null
    Write-Host "Destination mailbox with address $($fwdsmtp) does not exist." -foregroundcolor red
    "Destination mailbox with address $($fwdsmtp) does not exist." | Out-File -filepath $logfile -append
}

Having verifed the inputs, the next step is to preopare the variables required for the rest of the script. The entire process is contained within an if-test requiring the inputs to be valid; the else-loop displays an invalid input notification and terminates.

        # Prepare variables
        $name="Forward for $($mbxsmtp)"
        $alias=$caseref
        $OU="<Canonical name of OU where object should be created>"
        $UPN=$caseref+"@<domain FQDN>"
        $sam=$caseref
        $plainpass = New-Password
        $password = $plainpass | ConvertTo-SecureString -AsPlainText -Force

Next, we remove the SMTP address for the mailbox in question. This is required because when a leaver is processed, their mailbox will normally be removed within 28 days or so and the forward will need to remain in place longer than that. So in order to handle everything at once, the SMTP address is transferred at the same time as the forwarding is configured. If all steps are successful the $continue flag is set as true.

# Change email address for source mailbox.
try {
    Set-Mailbox $mbx -EmailAddressPolicyEnabled $false -Confirm:$false -Erroraction Stop
    $smtp=$mbxsmtp -replace ("@<domain>","@local.<domain>")
    Set-Mailbox $mbx -PrimarySMTPAddress $smtp
    Set-Mailbox $mbx -EmailAddresses @{Remove=$mbxsmtp}
    Set-Mailbox $mbx -DeliverToMailboxAndForward $false -ForwardingAddress $fwdsmtp -ErrorAction Stop
    $continue=$true
    Write-Host "Successfully updated the primary SMTP address for source mailbox." -foregroundcolor green
    "Successfully updated the primary SMTP address for source mailbox." | Out-File -filepath $logfile -append       
   
} catch {
    $continue=$false
    Write-Host "Unable to update source mailbox primary SMTP address. Error message was:`n$($_.Exception.Message)`nScript will now halt." -foregroundcolor red
    "Unable to update source mailbox primary SMTP address. Error message was:`n$($_.Exception.Message)`nScript will now halt." | Out-File -filepath $logfile -append
}

Now that the SMTP address is available, we can create a new forwarding mailbox. In theory this can probably also be done by using a mail-enabled user rather than a mailbox-enabled user, but again the script as written is intended to replicate the existing process rather than change process on the fly. In what could probably be described as excessive caution I’ve put each step in this process within its own try-catch loop; the steps can be combined within one try-catch loop as with the previous section, but it means there is potentially less useful diagnostif information if something goes wrong.

       
# Create forwarding mailbox and set configuration as required.
if ($continue) {
    try {
        New-Mailbox -Name $name -Alias $caseref -OrganizationalUnit $OU -UserPrincipalName $UPN -SamAccountName $sam -Firstname "" -Initials "" -Lastname "" -Password $password -ResetPasswordOnNextLogon $false -ErrorAction Stop
        $success=$true
        Write-Host "Forwarding mailbox created." -foregroundcolor green
        "Forwarding mailbox created." | Out-File -filepath $logfile -append
    } catch {
        $success=$false
        Write-Host "Unable to create forwarding mailbox. Error message was:`n$($_.Exception.Message)`nScript will now halt." -foregroundcolor red
        "Unable to create forwarding mailbox. Error message was:`n$($_.Exception.Message)`nScript will now halt." | Out-File -filepath $logfile -append
    }
    if ($success) {
        try {
            Set-Mailbox $name -EmailAddresses @{Add=$mbxsmtp} -ErrorAction Stop
            $success=$true
            Write-Host "Forwarding mailbox SMTP address updated." -foregroundcolor green
            "Forwarding mailbox SMTP address updated." | Out-File -filepath $logfile -append
        } catch {
            $success=$false
            Write-Host "Unable to update SMTP address for forwarding mailbox. Error message was:`n$($_.Exception.Message)`nScript will now halt." -foregroundcolor red
            "Unable to update SMTP address for forwarding mailbox. Error message was:`n$($_.Exception.Message)`nScript will now halt." | Out-File -filepath $logfile -append               
        }
    }
    if ($success) {   
        try {
            Set-Mailbox $name -PrimarySMTPAddress $mbxsmtp -EmailAddressPolicyEnabled $false -ErrorAction Stop
            $success=$true
            Write-Host "Forwarding mailbox primary SMTP configured." -foregroundcolor green
            "Forwarding mailbox primary SMTP configured." | Out-File -filepath $logfile -append
        } catch {
            $success=$false
            Write-Host "Unable to update primary SMTP address for forwarding mailbox. Error message was:`n$($_.Exception.Message)`nScript will now halt." -foregroundcolor red
            "Unable to update primary SMTP address for forwarding mailbox. Error message was:`n$($_.Exception.Message)`nScript will now halt." | Out-File -filepath $logfile -append               
        }
    }
    if ($success) {
        try {
            Set-Mailbox $name -DeliverToMailboxAndForward $false -ForwardingAddress $fwdsmtp -HiddenFromAddressListsEnabled $true -ErrorAction Stop
            $success=$true
            Write-Host "Forwarding mailbox configured and hidden from GAL." -foregroundcolor green
            "Forwarding mailbox configured and hidden from GAL." | Out-File -filepath $logfile -append
        } catch {
            $success=$false
            Write-Host "Unable to configure forwarding on forwarding mailbox. Error message was:`n$($_.Exception.Message)`nScript will now halt." -foregroundcolor red
            "Unable to configure forwarding on forwarding mailbox. Error message was:`n$($_.Exception.Message)`nScript will now halt." | Out-File -filepath $logfile -append
        }
    }
}

And we’re done. This last section is to display a list of actions taken along with their results:

# Display actions taken
if ($success) {
    $srcsmtp=(Get-Mailbox $mbx).PrimarySMTPAddress
    $destsmtp=(Get-Mailbox $name).PrimarySMTPAddress
    $fwdmbxsmtp=(Get-Mailbox $name).ForwardingAddress
    $fwdgal=(Get-Mailbox $name).HiddenFromAddressListsEnabled
    Write-Host "All steps finished successfully.`nSource mailbox now has the primary SMTP address $($srcsmtp).`nForwarding mailbox has the primary SMTP address $($destsmtp).`nForwardingmailbox has the forwarding address $($fwdmbxsmtp)`nForwarding mailbox is hidden from GAL: $($fwdgal)." -foregroundcolor green
    "All steps finished successfully." | Out-File -filepath $logfile -append
    "Source mailbox now has the primary SMTP address $($srcsmtp)." | Out-File -filepath $logfile -append
    "Forwarding mailbox has the primary SMTP address $($destsmtp)." | Out-File -filepath $logfile -append
    "Forwardingmailbox has the forwarding address $($fwdmbxsmtp)" | Out-File -filepath $logfile -append
    "Forwarding mailbox is hidden from GAL: $($fdwgal)." | Out-File -filepath $logfile -append
} else {
    $srcsmtp=(Get-Mailbox $mbx).PrimarySMTPAddress.ToString()
    Write-Host "One or more steps were unsuccessful:`nSource mailbox currently has the primary SMTP address $($srcsmtp)." -foregroundcolor yellow
    "One or more steps were unsuccessful:" | Out-File -filepath $logfile -append
    "Source mailbox currently has the primary SMTP address $($srcsmtp)." | Out-File -filepath $logfile -append
    if ((Get-Mailbox $mbx).ForwardingAddress) {
        Write-Host "Source mailbox is forwarding to $((Get-Mailbox $mbx).ForwardingAddress)." -foregroundcolor yellow
        "Source mailbox is forwarding to $((Get-Mailbox $mbx).ForwardingAddress)." | Out-File -filepath $logfile -append
    } else {
        Write-Host "Source mailbox does not have forwarding configured." -foregroundcolor red
        "Source mailbox does not have forwarding configured." | Out-File -filepath $logfile -append
    }
   
    if (Get-Mailbox $name) {
        Write-Host "Forwarding mailbox exists, with primary SMTP address $((Get-Mailbox $name).PrimarySMTPAddress)." -foregroundcolor yellow
        "Forwarding mailbox exists, with primary SMTP address $((Get-Mailbox $name).PrimarySMTPAddress)." | Out-File -filepath $logfile -append
        if ((Get-Mailbox $name).ForwardingAddress) {
            Write-Host "Forwarding mailbox has the forwarding SMTP address $((Get-Mailbox $name).ForwardingAddress)." -foregroundcolor yellow
            "Forwarding mailbox has the forwarding SMTP address $((Get-Mailbox $name).ForwardingAddress)." | Out-File -filepath $logfile -append
        } else {
            Write-Host "Forwarding mailbox does not have a forwarding SMTP address." -foregroundcolor red
            "Forwarding mailbox does not have a forwarding SMTP address." | Out-File -filepath $logfile -append
        }
    } else {
        Write-Host "Forwarding mailbox does not exist." -foregroundcolor red
        "Forwarding mailbox does not exist." | Out-File -filepath $logfile -append               
    }
}

That’s it for now. Next week I’ll be posting a few scripts for managing mailbox permissions.

Automatically identifying, retrieving and installing an update for installed software

Last week, I wrote about how to create and export a list of installed software using PowerShell. I mentioned that while this might be of limited use by itself, it could be integrated with other scripts to exercise control over the software installed on a given system. This is what I want to write about today.

As a starting point, consider this simple outline for a hypothetical script-based software manager:

function Get-InstalledSWList {
	See last post for an example of how to do this
}

function Check-InstalledSW {
	# Read values from InstalledSWList, parse into usable format
	
	# Retrieve related values from some defined source eg where to check for updates
	
	# Check for update availability
}	

function Update-InstalledSW {
	# Retrieve updated version if available
	
	# Install updated version
}

function Remove-InstalledSW {
	# Optional - for licencing-restricted software, this function could be invoked to remove software from a machine for which no licence is available.
}

If you’ve ever dealt with software packaging/updating you know how tediously complicated this can get for the range of software packages, installation types and installation sources that exist. So for this post I’m going to use a specific example of how the above functions would work for a specific package; this will illustrate how useful PowerShell could be in this context, but also the non-trivial amount of work involved in creating an initial profile of managed software packages.

I’m going to use Firefox as an example, not least because an update has been released in the last few days. Looking at the output file for last week’s script and choosing the row for Firefox, I get a series of entries as follows:

#	Registry Key Title			Display Name				Display Version	Install Location			Uninstall string
92	Mozilla Firefox 32.0.3 (x86 en-GB)	Mozilla Firefox 32.0.3 (x86 en-GB)	32.0.3		C:\Program Files (x86)\Mozilla Firefox	C:\Program Files (x86)\Mozilla Firefox\uninstall\helper.exe

Additional information I happen to know about Firefox that makes this a bit easier; the installer supports both in-place upgrades and silent installations (using the switch ‘-ms’). This means that we don’t have to go through the tedium of completely removing it before installing the new version, because the FF installer takes care of all the housekeeping natively (unlike some installers I’ve seen from companies that should know better *cough cough* Skype *cough cough* Java).

So the first things we want to do are go parse these values and figure out what information we need. Obviously, Display Version is important and easily useful. Ditto Install Location (although in this case since it’s installed in the default location we can ignore it). However, the language version is also relevant and not so useful – it’s part of the Display Name. So we need to parse that and use regular expressions to get the part we want.

Assume that the Get-InstalledSW function has made available the following variables for us:

$displayversion="32.0.3"
$displayname="Mozilla Firefox 32.0.3 (x86 en-GB)"
$installocation="C:\Program Files (x86)\Mozilla Firefox"

To extract the part of the Display Name that we want, we can do the following:

# Create a new, empty array
$fftypesplit=@()

# Split the display version using an easy delimiter - in this case "(x86 " - as a regular expression
$displayname -split '\(x86' | foreach {$fftypesplit+= $_}

# Remove the closing bracket using a Replace string operation
$fflang = $fftypesplit[1].Replace(")","")

We now have the additional variable $fflang, with the value “en-GB”. What next?

Next we need to check if there’s a newer version of the software available. As I alluded to above, this part can be extraordinarily tedious due to the range of distribution mechanisms used by software publishers, even for free-to-use software. In the case of Firefox, we’re going to start with the releases page, at https://www.mozilla.org/en-US/firefox/releases/.

# Declare a variable for the releases page URL
$ffreleases = "https://www.mozilla.org/en-US/firefox/releases/"

# Use Invoke-WebRequest to create a new variable populated with all the arrays on the releases page
$FFVerList = (Invoke-WebRequest -URI $ffreleases).Links.Href

So now we have an array of links in an array. At first glance, this doesn’t seem much help:

/en-US/
/en-US/firefox/desktop/
/en-US/firefox/desktop/
/en-US/firefox/desktop/customize/
/en-US/firefox/desktop/fast/
https://developer.mozilla.org/docs/Tools
/en-US/firefox/desktop/trust/
/en-US/firefox/android/
/en-US/firefox/android/
https://addons.mozilla.org/android/
/en-US/firefox/android/faq/
/en-US/firefox/channel/
/en-US/firefox/channel/
/en-US/firefox/channel/#aurora
/en-US/firefox/channel/#beta
/en-US/firefox/
/en-US/firefox/organizations/
https://addons.mozilla.org/
https://addons.mozilla.org/firefox/
https://addons.mozilla.org/android/
https://addons.mozilla.org/firefox/themes/
https://support.mozilla.org/
https://support.mozilla.org/products/firefox
https://support.mozilla.org/products/mobile
/en-US/firefox/about/
Home
/en-US/firefox/about/ /join /en-US/about/participate/ https://blog.mozilla.org/press/ /en-US/firefox/brand/ https://careers.mozilla.org /en-US/about/partnerships.html /en-US/contact/spaces/ /en-US/firefox/ /en-US/ /en-US/firefox/new/ ../33.0/releasenotes/ ../32.0/releasenotes/ ../32.0.1/releasenotes/ ../32.0.2/releasenotes/ ../32.0.3/releasenotes/ ../31.0/releasenotes/ ../31.2.0/releasenotes/ ../31.1.0/releasenotes/ ../31.1.1/releasenotes/ ../30.0/releasenotes/

But after an initial set of irrelevant links we get to what we want – named directories for each release. So we can use regular expressions to identify only those links relating to specific releases, and then compare strings to check if a newer release exists.

# Iterate through each link
foreach ($link in $FFVerList) {
	
	# Check if the link starts by going up a level, and ignore it if it does not.
	if ($link.StartsWith("../") {
	
		# Extract the release version number as string by removing the "../" prefix and then the "/releasenotes/" suffix
		$linkver=($link.Replace("../",""))
		$linkver=($linkver.Replace("/releasenotes/",""))

		# Use the version type to compare $linkver to $ffver, and set a variable if $linkver is greater.
		if ([version]$linkver -gt [version]$ffver) {
			
			# In case more than one new release exists, we also check if the $newver variable has been set already and if so, whether we need to change it for a newer version.
			if ($newver) {
				# Compare the current version against the existing value for $newver, and update $newver if the current version is greater
				if ([version]$linkver -gt [version]$newver) {
					$newver=$linkver
					$needs_update = $true
				}
			} else {
				$newver=$linkver
				$needs_update = $true
		}
	}
}
# After iterating through the list, check if $newver has been declared and if so, write the details to screen
if ($newver) {
	Write-Host "A new version is available, with version number "$newver
	Write-Host " "
} else {
	Write-Host "The current installation is already running the latest version available, version "$ffver
	Write-Host " "
}

Now we’ve got a boolean variable $needs_update that we can use to decide whether to invoke the Update-InstalledSW function, and the new version (if it exists) as $newver. Next, we need to define our source and destination files because we’re using Invoke-WebRequest again.

For the source file, I went to the Mozilla website using Chrome, selected the language I wanted (en-GB, listed as “English (British)” in the dropdown menu) and then retrieved the link location for the file that is offered for download. That link is:

https://download-installer.cdn.mozilla.net/pub/firefox/releases/33.0/win32/en-GB/Firefox%20Setup%20Stub%2033.0.exe

I want to be able to match the language and versions to whatever the script outputs, so I define a new variable using the format of the link:

# Assemble the link using the $newver and $fflang variables where required
$source = "https://download-installer.cdn.mozilla.net/pub/firefox/releases/"+$newver+"/win32/"+$fflang+"/Firefox%20Setup%20Stub%20"+$newver+".exe"

# Define a name and path for the output file
$dest= $env:temp+"\Firefox_"+$newver+"_"+$fflang+".exe"

Now we’re ready to use Invoke-WebRequest to retrieve the new installation package from Mozilla:

Invoke-WebRequest -URI $source -Outfile $dest

We’re nearly finished now; we just need to install the new version. Before we can do that, though, we need to check for any existing instances of Firefox already running on the system and close them down. We might also want to notify the user that the browser needs to be closed to install the update and wait for confirmation before proceeding. One last block of scripting should see us through:

# Check for running instances of Firefox
try {
	$running = (Get-Process -ProcessName "firefox" -ErrorAction SilentlyContinue)
} catch {
	$running = $false
}
# Where an active instance of Firefox is detected, prompt the user for input. This part is optional but good practice as it ensures that the end user has a chance to save any open documents or work in progress and allows them to defer the installation if they are busy.
if ($running) {
	do {
		Clear-Host
		$input = Read-Host("A new version of Firefox is available and ready to install. This requires that all existing browser sessions be closed. Please save your work and close the browser, then press Y to proceed. Alternatively, press N to defer the update to a later time")
		switch ($input) {
			"Y" {
				$proceed = $true
				$choice = $true
			}
			"N" {
				$proceed = $false
				$choice = $true
			}
			default {
				Write-Host "Invalid selection entered, please try again."
				Start-Sleep 3
			}
		}
	} While (!$choice)
	
	# Check if the user selected to proceed with the installation or not
	if ($proceed) {
		# If the user selected to proceed, check if Firefox is still running or not
		$stillrunning = (Get-Process -ProcessName "firefox" -ErrorAction SilentlyContinue)
		
		# While it is still running, iterate the Stop-Process cmdlet until no instances are found
		do {
			Stop-Process -ProcessName "firefox" -Force
			try {
				$stillrunning = (Get-Process -ProcessName "firefox" -ErrorAction SilentlyContinue)
			} catch {
				$stillrunning = $false
			}
		} while ($stillrunning)
		
		# When we're ready to proceed, call the software installer
		try {
			cmd /c $dest -ms
			Write-Host "Firefox updating process complete; you may now relaunch the browser."
			Start-Sleep 10
		} catch {
			Write-Host "Unexpected error when calling installer, error message was "$_.Exception.Message
		}
	} else {
		Write-Host "You have selected to defer the installation. This script will now terminate."
	}
} else {
	# Proceed with installation as no active instances are detected
	try {
		cmd /c $dest -ms
		Write-Host "Firefox updating process complete; you may now relaunch the browser."
		Start-Sleep 10
	} catch {
		Write-Host "Unexpected error when calling installer, error message was "$_.Exception.Message
	}
}

Finished!

I think it’s now clear why this approach is versatile but non-trivial if implemented across a wide software inventory. If we revise the initial script I suggested to more closely match the steps I have described above, fleshing out the optional components for licence-compliance, we end up with something more like this:

function Get-InstalledSWList {
	# See last post for an example of how to do this
}

function Get-ManagedSWList {
	# Retrieve related values from a defined source or location. Must include: the process name when software is running; the silent install switch; boolean to indicate whether in-place upgrades are supported. May also require: per-item function to retrieve the latest release version; boolean to determine whether updates should be applied automatically or not ; approximate size of install file download; boolean for restricted (eg commercially licenced) software
}	

function Get-MachineSWLicences {
	# Retrieve a list of software packages and versions which are commercially licenced for this client
}
	
function Check-InstalledSW {
	# Read values from InstalledSWList and MachineSWLicences, parse into usable format and compare with ManagedSWList
	
	# Identify packages which require action (actions may be Install, Update, Remove)
}	
 
function Get-SWUpdates {
	# Check network type to ensure that charges will not be incurred, potentially taking into account total expected filesize for available updates
	
	# Download all available updates, populate an array with their locations
}

function Install-SWUpdates {
	# Iterate through the array created by Get-SWUpdates, installing updates sequentially
}

function Get-LicencedSW {
	# Check network type to ensure that charges will not be incurred, potentially taking into account total expected filesize for available updates

	# For any licenced software which is not already installed, retrieve installation media and populate an array with their locations
	
	# For any licenced software which is installed but not licenced, populate an array with the relevant information
}

function Install-LicencedSW {
	# Iterate through the array created by Get-LicencedSW, install software packages sequentially. This would need to take into account pre-requisite components and potential reboot requirements for installation completion.
}	

function Remove-LicencedSW {
	# Iterate through the array created by Get-LicencedSW, remove packages where required and notify both the primary user and administrator for the machine of the actions taken.
}

Which is still a very high-level conceptual mapping of the overall process, and doesn’t take into account the probable requirement to scan locations other than the registry for the presence of software. For example, it might be desirable to get an array of all directories under Program Files/Program Files (x86) and compare that to the list of directories under Program Files/Program Files (x86) that we get from Get-InstalledSWList to identify any anomalies. Or it may be desirable to scan an entire filesystem’s non-OS directories for executable files and cehck them against a profile of known or allowed locations.

I do intend to pursue this further; it’s something that interests me and I’ve found that even where free-to-use tools exist for end users (such as the promising-but-still-buggy Secunia Personal Security Inspector) they are more resource intensive and demanding than I think is necessarily required. If nothing else, having a script or set of scripts I can use that will reliably check at least the most common web-facing applications installed on machines (whether systems I manage for an employer or those owned by friends and family who ask me for assistance) would be useful.

Getting a per-system software inventory

This latest script is a bit of a swerve from my last post, but I was reading about PowerShell providers recently and wanted to experiment with the Registry provider, since in the past I’ve learned the hard way that any significant amount of registry work in batch files is painful at best.

My intention with this script is to generate a CSV file containing details of all software registered with the Windows Installer in the registry. This can be useful not just for tracking installed software in environments where centralised systems for doing this are not feasible (whether  for organisational size or budgetary reasons), but also for identifying out-dated or licencing-restricted software that requires the attention of an administrator. At some point in the near future I’ll write another script that processes the output of this script and a set of rules provided by the administrator to take required actions, which can include removing unauthorised software, installing an updated software release or notifying the administrator of specific installed packages. Beyond that, my idealised version would be tied into a network resource (be it a network share or a webserver) from which updated rulesets and installation media could be derived.

The fundamentals of this script are to iterate through the Uninstall registry key subkeys (for both 32-bit and 64-bit software installs) to get a list of installed software and related details. The principles used in this script can also be used to update the registry information for software installers which don’t follow best practice (eg developers who think that putting the version number in the DisplayName is a good idea). There are other ways around this (such as using Orca to edit MSI installation packages) but they are not always available, and in some cases (such as requiring signed installation packages) it’s better to just modify the registry information for the installed software than to change the installed package.

One thing to note is that if you switch from the Environment provider to the Registry provider, you need to switch back to the Environment provider to use file-based cmdlets like Out-File. This means that if you interrupt the script before it finishes, the ISE or PowerShell session will most likely still have its location set as the Registry provider. That’s enough for now – let’s get on with looking at the script itself.

function Get-InstalledSoftware {
    # Set the location as the registry HKLM node:
    Set-location HKLM:

    # Initialise the hashtable
    $global:swlist=$null
    $global:swlist= @{}

    # Define the paths for software installations and the methods for retrieving a list of installed software under those paths
    $32bitswpath = "Software\WoW6432Node\Microsoft\Windows\CurrentVersion\Uninstall"
    $32bitswlist = gci -path $32bitswpath
    
    # Iterate through items in the list of 32-bit installation registry keys, extracting the properties we want 
    $i= 0
    do {
        # Retrieve the properties we need:
        $currobj = (Get-ItemProperty -path $32bitswlist[$i])
        if ($currobj.DisplayName) {
            $displayname = $currobj.DisplayName.ToString()
        }
        if ($currobj.DisplayVersion) {
            $displayversion = $currobj.DisplayVersion.ToString()
        }
        if ($currobj.InstallLocation) {
            $installlocation = $currobj.InstallLocation.ToString()
        }
        if ($currobj.UninstallString) {
            $uninstallstring = $currobj.UninstallString.ToString()
        }
        # In case of entries with null values for the above, also grab the PSChildName attribute
        $psName = $32bitswlist[$i].PSChildName

        # Assign the properties to a new row in the array
        try {
            $global:swlist.Add("$i",@{"Registry Key Name" = $psName; "DisplayName" = $displayname;"DisplayVersion" = $displayversion;"InstallLocation" = $installlocation;"UninstallString" =$uninstallstring})
            Clear-Variable -Name psName,displayname,displayversion,installlocation,uninstallstring -force -ErrorAction SilentlyContinue
        } catch {
            Write-Host "Unexpected problem getting item properties for path "$32bitswlist[$i]
            WRite-Host "Error message was" $_.Exception.Message
            Write-Host " "
        }
        $i++
    } while ($i -lt $32bitswlist.count)

    if ($OStype -eq "64-bit") {
        $64bitswpath = "Software\Microsoft\Windows\CurrentVersion\Uninstall"
        $64bitswlist = gci -path $64bitswpath
    
        # Repeat for items in the list of 64-bit installation registry keys, extracting the properties we want. Since we're using the counter as an index in the hashtable, we don't reset $i to zero but instead add a second counter based on it.
        do {
            $j = ($i - $32bitswlist.count)
            # Retrieve the properties we need:
            $currobj = (Get-ItemProperty -path $64bitswlist[$j])
            if ($currobj.DisplayName) {
                $displayname = $currobj.DisplayName.ToString()
            }
            if ($currobj.DisplayVersion) {
                $displayversion = $currobj.DisplayVersion.ToString()
            }
            if ($currobj.InstallLocation) {
                $installlocation = $currobj.InstallLocation.ToString()
            }
            if ($currobj.UninstallString) {
                $uninstallstring = $currobj.UninstallString.ToString()
            }
            # In case of entries with null values for the above, also grab the PSChildName attribute
            $psName = $64bitswlist[$j].PSChildName

            # Assign the properties to a new row in the array
            try {
                $swlist.Add("$i",@{"Registry Key Name" = $psName; "DisplayName" = $displayname;"DisplayVersion" = $displayversion;"InstallLocation" = $installlocation;"UninstallString" =$uninstallstring})
            } catch {
                Write-Host "Unexpected problem getting item properties for path "$64bitswlist[$j]
                WRite-Host "Error message was" $_.Exception.Message
                Write-Host " "
            }
            Clear-Variable -Name j,psName,displayname,displayversion,installlocation,uninstallstring -force -ErrorAction SilentlyContinue
            $i++
        } while (($i - $32bitswlist.count) -lt $64bitswlist.count)
    }
#>
}

function Export-SoftwareList ($swlist) {
    # Define the output file name, using both the local computer's hostname and the date & time at which the file is created.
    $output = "$source"+"\"+"Installed_Software_"+$env:Computername+"_"+"$OStype"+"_"+(Get-date -format yyyyMMddHHmm)+".csv"

    # Define the desired headers for the CSV output file
    $headers = "Index,Registry Key Name,Display Name,Display Version,Install Location,Uninstall String"

    # Output the headers to the file
    $headers | Out-File $output -append

    # Iterate through each entry in the swlist hashtable, creating a comma-separated string that can be piped to the output file.
    $k=0
    do {
        $reg=$swlist["$k"]["Registry Key Name"]
        $dispname=$swlist["$k"]["DisplayName"]
        $dispver=$swlist["$k"]["DisplayVersion"]
        $instloc=$swlist["$k"]["InstallLocation"]
        $uninst=$swlist["$k"]["UninstallString"]

        "$k"+","+$reg+","+$dispname+","+$dispver+","+$instloc+","+$uninst | Out-File $output -append
        $k++
    } while ($k -lt $swlist.count)
}

# Capture the starting location & PSProvider for the script
$source=(Get-Location).Path

# Capture the OS architecture
$OStype = ((Get-WMIObject -Class win32_OperatingSystem -ComputerName $env:ComputerName).OSArchitecture)

# Call the inventory functions
Get-InstalledSoftware
Export-SoftwareList $swlist

# Switch back to the starting location & PSProvider
set-location $source

Spoken notifications with PowerShell

While researching the background for my Estimated Network Usage script, I happened to learn about an interesting possibility for using COM objects – using the SAPI.spvoice object type as a COM object for text-to-speech conversion.

This got me thinking – text-to-speech functionality doesn’t appear to be a particularly obvious solution to many problems, but it could be useful for delivering notifications in PowerShell. Most posts I’ve seen about the Text-to-Speech functionality have discussed it as a way of playing jokes on colleagues, but I can imagine it being useful in circumstances where a status or error message needs to be brought to the operator/sysadmin’s attention as soon as possible. To try this out, I wrote a menu-driven script with functions for selecting the voice to use (in Windows 7 only one voice is present by default; as of Windows 8 there appear to be three voices installed by default), setting the voice parameters (rate of speech, volume of speech), entering a message and reading out the message. Based on playing around with it, I wouldn’t want to rely on it as my only status notification, but I can see it being useful for quickly highlighting issues with jobs that run in the background, especially jobs that take a long time to run.

function Choose-Voice {
    Clear-Host
    Write-Host "Voice selection menu:"
    WRite-Host " "
	if ($accents.count -gt 1) {
		Write-Host "There are "$accents.count "voices available. Voices available are:"
		$j=0
		foreach ($accent in $accents) {
			Write-Host "Accent " $j ": " $accent.GetDescription() 
			Write-Host " "
			$j++
			}
		$validchoice = $false	
		$accentcount=($accents.count -1)
		while (!$validchoice) {
			try {
				[int]$choice = Read-Host("To select a voice, enter a number between 0 and " +$accentcount)
				if (($choice -le $accentcount) -and ($choice -ge "0")) {
					try {
						$voice.Voice=$accents[$choice]
                        Write-Host "Voice has been set to"$accents[$choice].GetDescription()
                        Start-Sleep 2
						$validchoice=$true
					} catch {
						Write-Host "Unable to set selected voice, please try again."
                        Start-Sleep 2
					}
				} else {
					Write-Host "Invalid selection entered, please try again."
                    Start-Sleep 2
				}
			} catch {
				Write-Host "An error occured with the selection you entered. Please try again."
                Start-Sleep 2
			}	
		}
	} else {
		Write-Host "Only the voice" $accents.Item(0).GetDescription() "is available on this system."
        Start-Sleep 2
	}
}

function Set-VoiceParameters {
	$done = $false
	while (!$done) {
		Clear-Host
		Write-Host "Voice parameters menu:"
        Write-Host " "
		Write-Host "The volume for the current voice is" $voice.Volume
		Write-Host "The rate for the current voice is" $voice.Rate
        Write-Host " "
		Write-Host "To change the volume, press 1."
		Write-Host "To change the rate, press 2."
		Write-Host "When you have finished making changes, press 3."
        Write-Host " "
		$parammenu=Read-Host ("Please enter your choice")
		switch ($parammenu) {
			"1" {
				try {
					[int]$newvolume=Read-Host("Please enter a number between 1 and 100 to set as the new volume for the current voice")
					$voice.volume=$newvolume
				} catch {
					Write-Host "Invalid value entered."
                    Start-Sleep 2
				}
			}
			"2" {
				try {
					[int]$newrate=Read-Host("Please enter an integer to set as the new rate for the current voice")
					$voice.rate=$newrate
				} catch {
					Write-Host "Invalid value entered."
                    Start-Sleep 2
				}
			}
			"3" {
				$done=$true
			}
			default {
				Write-Host "Invalid selection entered, please try again."
                Start-Sleep 2
			}
		}
	}
}

function Set-Message {
    Clear-Host
    Write-Host "Message entry menu:"
    Write-Host " "
	$finished = $false
	while (!$finished) {
		try {
			$global:msg=Read-Host("Please type your message and press enter to confirm")
            Write-Host "Message has been set. You will return to the main menu momentarily."
            Start-Sleep 1
			$finished = $true
		} catch {
			Write-Host "An error occured with the message you entered, please try again."
            Start-Sleep 2
		}
	}
}

function PS-Speak {
    Clear-Host
	if ($msg -eq $null) {
		$i=0
        if ($accents) {
		    foreach ($accent in $accents) {
			    $voice.Voice=$accent
			    $msg = "This is voice " +$i +" . This voice is called, " 
			    $msg += $accent.GetDescription()
			    $voice.Speak($msg)
			    $msg = $null
                $i++
		    }
        } else {
            $voicename=$voice.GetVoices()
            $msg = "This is the only voice on this system. This voice is called "
            $msg += $voicename.Item[0].GetDescription()
            $voice.Speak($msg)
            $msg=$null
        } 
	} else {
		$voice.Speak($msg)
	}
}



# Main body

# Set voice variable
$voice = new-object -comobject SAPI.SPvoice;
$accents = $voice.GetVoices()

#Start screen menu:
$menu=@"
PowerShell Speaks!

To select a voice, press 1.
To change the voice parameters, press 2.
To set the message to read, press 3.
To read your message or test available voices, press 4.
To quit, press 5.
"@

$quit=$false
While (!$quit) {
	Clear-Host
	Write-Host $menu
	[int]$menuchoice = Read-Host("Please enter your selection")
	switch ($menuchoice) {
		"1" {
			Choose-Voice
		}
		"2" {
			Set-VoiceParameters
		}
		"3" {
			Set-Message
		}
		"4" {
			PS-Speak
		}
		"5" {
			$quit=$true
		}
		default {
			Write-Host "Invalid selection entered, please try again."
            Start-Sleep 2
		}
	}
}