Creating .CAB files with Powershell

During our on-going development of online updateable Powershell help (more on that later) you quickly come to realise that each culture-specific set of help files is stored within its own 1980’s style cabinet (or .cab) file. When packing the .cab files for release, I like to automate (if you hadn’t already guessed!) as it makes things quick, easy and certainly less error prone.

Working with .cab files in Powershell requires use of MAKECAB.EXE which, fortunately, is distributed with each edition of Windows. In the good ol’ days we could use the MakeCab.MakeCab.1 COM object, but this has been deprecated since Windows Vista. I had a quick Google and couldn’t find anything easily reusable (except Ed Wilson’s post here) and thus this blog post.

I set to work, creating a Powershell advanced function that would easily allow me to package each culture-specific help file (or one or more files) into its own cabinet file. It would probably be more prudent to utilise .NET’s StringBuilder class but performance is not (currently) an issue or priority!

You never know I might come back and show you how we use this with Psake in the future… Here are some examples of how you can use it:

EXAMPLE

New-CabinetFile -Name MyCabinet.cab -File "File01.exe","File02.txt"

This creates a new MyCabinet.cab file in the current directory and adds the File01.exe and File02.txt files to it, also from the current directory.
EXAMPLE

Get-ChildItem C:\CabFile\ | New-CabinetFile -Name MyCabinet.cab -DestinationPath C:\Users\UserA\Documents

This creates a new C:\Users\UserA\Documents\MyCabinet.cab file and adds all files within the C:\CabFile\ directory into it.

Here’s the full advanced function:

<#
.SYNOPSIS
    Creates a new cabinet .CAB file on disk.

.DESCRIPTION
    This cmdlet creates a new cabinet .CAB file using MAKECAB.EXE and adds
    all the files specified to the cabinet file itself.

.PARAMETER Name
    The output file name of the cabinet .CAB file, such as MyNewCabinet.cab.
    This should not be the entire file path, only the target file name.

.PARAMETER File
    One or more file references that are to be added to the cabinet .CAB file.
    FileInfo objects (as generated by Get-Item etc) or strings can be passed
    in via the pipeline to be added to the cabinet file.

.PARAMETER DestinationPath
    The output file path that the cabinet file will be saved in. It is also
    used for resolving any ambiguous file references, i.e. any file passed in
    via file name and not full path.

    If not specified the current working directory is used for the output file
    and attempting to resolve all ambiguous file references.

.PARAMETER NoClobber
    Will not overwrite of an existing file. By default, if a file exists in the
    specified path, New-CabinetFile overwrites the file without warning.

.EXAMPLE
    New-CabinetFile -Name MyCabinet.cab -File "File01.exe","File02.txt"
    
    This creates a new MyCabinet.cab file in the current directory and adds the File01.exe and File02.txt files to it, also from the current directory.
.EXAMPLE
    Get-ChildItem C:\CabFile\ | New-CabinetFile -Name MyCabinet.cab -DestinationPath C:\Users\UserA\Documents

    This creates a new C:\Users\UserA\Documents\MyCabinet.cab file and adds all files within the C:\CabFile\ directory into it.
#>
function New-CabinetFile {
    [CmdletBinding()]
    Param(
        [Parameter(HelpMessage="Target .CAB file name.", Position=0, Mandatory=$true, ValueFromPipelineByPropertyName=$true)]
        [ValidateNotNullOrEmpty()]
        [Alias("FilePath")]
        [string] $Name,

        [Parameter(HelpMessage="File(s) to add to the .CAB.", Position=1, Mandatory=$true, ValueFromPipeline=$true)]
        [ValidateNotNullOrEmpty()]
        [Alias("FullName")]
        [string[]] $File,

        [Parameter(HelpMessage="Default intput/output path.", Position=2, ValueFromPipelineByPropertyName=$true)]
        [AllowNull()]
        [string[]] $DestinationPath,

        [Parameter(HelpMessage="Do not overwrite any existing .cab file.")]
        [Switch] $NoClobber
        )

    Begin { 
    
        ## If $DestinationPath is blank, use the current directory by default
        if ($DestinationPath -eq $null) { $DestinationPath = (Get-Location).Path; }
        Write-Verbose "New-CabinetFile using default path '$DestinationPath'.";
        Write-Verbose "Creating target cabinet file '$(Join-Path $DestinationPath $Name)'.";

        ## Test the -NoClobber switch
        if ($NoClobber) {
            ## If file already exists then throw a terminating error
            if (Test-Path -Path (Join-Path $DestinationPath $Name)) { throw "Output file '$(Join-Path $DestinationPath $Name)' already exists."; }
        }

        ## Cab files require a directive file, see 'http://msdn.microsoft.com/en-us/library/bb417343.aspx#dir_file_syntax' for more info
        $ddf = ";*** MakeCAB Directive file`r`n";
        $ddf += ";`r`n";
        $ddf += ".OPTION EXPLICIT`r`n";
        $ddf += ".Set CabinetNameTemplate=$Name`r`n";
        $ddf += ".Set DiskDirectory1=$DestinationPath`r`n";
        $ddf += ".Set MaxDiskSize=0`r`n";
        $ddf += ".Set Cabinet=on`r`n";
        $ddf += ".Set Compress=on`r`n";
        ## Redirect the auto-generated Setup.rpt and Setup.inf files to the temp directory
        $ddf += ".Set RptFileName=$(Join-Path $ENV:TEMP "setup.rpt")`r`n";
        $ddf += ".Set InfFileName=$(Join-Path $ENV:TEMP "setup.inf")`r`n";

        ## If -Verbose, echo the directive file
        if ($PSCmdlet.MyInvocation.BoundParameters["Verbose"].IsPresent) {
            foreach ($ddfLine in $ddf -split [Environment]::NewLine) {
                Write-Verbose $ddfLine;
            }
        }
    }

    Process {
   
        ## Enumerate all the files add to the cabinet directive file
        foreach ($fileToAdd in $File) {
        
            ## Test whether the file is valid as given and is not a directory
            if (Test-Path $fileToAdd -PathType Leaf) {
                Write-Verbose """$fileToAdd""";
                $ddf += """$fileToAdd""`r`n";
            }
            ## If not, try joining the $File with the (default) $DestinationPath
            elseif (Test-Path (Join-Path $DestinationPath $fileToAdd) -PathType Leaf) {
                Write-Verbose """$(Join-Path $DestinationPath $fileToAdd)""";
                $ddf += """$(Join-Path $DestinationPath $fileToAdd)""`r`n";
            }
            else { Write-Warning "File '$fileToAdd' is an invalid file or container object and has been ignored."; }
        }       
    }

    End {
    
        $ddfFile = Join-Path $DestinationPath "$Name.ddf";
        $ddf | Out-File $ddfFile -Encoding ascii | Out-Null;

        Write-Verbose "Launching 'MakeCab /f ""$ddfFile""'.";
        $makeCab = Invoke-Expression "MakeCab /F ""$ddfFile""";

        ## If Verbose, echo the MakeCab response/output
        if ($PSCmdlet.MyInvocation.BoundParameters["Verbose"].IsPresent) {
            ## Recreate the output as Verbose output
            foreach ($line in $makeCab -split [environment]::NewLine) {
                if ($line.Contains("ERROR:")) { throw $line; }
                else { Write-Verbose $line; }
            }
        }

        ## Delete the temporary .ddf file
        Write-Verbose "Deleting the directive file '$ddfFile'.";
        Remove-Item $ddfFile;

        ## Return the newly created .CAB FileInfo object to the pipeline
        Get-Item (Join-Path $DestinationPath $Name);
    }
}

 

Searching for String Properties with Powershell

I had a requirement recently to parse a configuration file (let’s just say for documentation purposes) and I needed to retrieve a property/value pair which may or may not be present in a text line. Now, depending on the product we wish to document, we might have a line in the configuration file constructed as follows:

set interface 1/3 -ifAlias DMZ -throughput 0 -bandwidthHigh 0 -bandwidthNormal 0 -intftype "Xen Virtual" -ifnum 1/3

Now, if wanted the “-intfType” value we could split the string using the Powershell .Split() method like so:

PS C:\> $SourceString = 'set interface 1/3 -ifAlias DMZ -throughput 0 -bandwidthHigh 0 -bandwidthNormal 0 -intftype "Xen Virtual" -ifnum 1/3';
$LineComponents = $SourceString.Split();
$LineComponents[12];
"Xen

However, there are two issues here:

  1. What happens if there are additional or missing properties (the index/order will change)?
  2. We were hoping/expecting to see “Xen Virtual” output and not just “Xen.

Here’s a quick function that will solve issue #1 (complete with case insensitivity):

#############################################################################
#.SYNOPSIS
# Get named property from a string.
#
#.DESCRIPTION
# Returns a case-insensitive property from a string, assuming the property is
# named before the actual property value and is separated by a space. For
# example, if the specified SearchString contained
# "-property1 <value1> -property2 <value2>”, searching # for "-Property1"
# would return "<value1>".
#
#.PARAMETER SearchString
# String to search for the specified property in.
#
#.PARAMETER PropertyName
# The property name to search for.
#
#.PARAMETER Default
# If the property is not found return the specified string. This parameter is
# optional and if not specified returns $null by default.
#
#.EXAMPLE
# $propertyValue = Get-StringProperty $StringToSearch "-property1"
#
#.EXAMPLE
# $propertyValue = Get-StringProperty $StringToSearch "-property3" "Not found"
##############################################################################
function Get-StringProperty([string]$SearchString, [string]$PropertyName, [string]$Default = $null)
{
    # Split the $SearchString based on one or more blank spaces
    $stringComponents = $SearchString.Split(' +',[StringSplitOptions]'RemoveEmptyEntries'); 
    for ($i = 0; $i -le $stringComponents.Length; $i++) {
        # The standard Powershell CompareTo method is case-sensitive
        if ([string]::Compare($stringComponents[$i], $PropertyName, $true) -eq 0) {
            # Check that we're not over the array boundary
            if ($i+1 -le $stringComponents.Length) {
                # If you wanted to trim quotation marks you could use this instead:
                #  return $StringComponents[$i+1].Trim('"');
                return $stringComponents[$i+1];
            }
        }
    }
    # If nothing has been found or we're over the array boundary, return the $EmptyString value
    return $Default;
}

$SourceString = 'set interface 1/3 -ifAlias DMZ -throughput 0 -bandwidthHigh 0 -bandwidthNormal 0 -intftype "Xen Virtual" -ifnum 1/3';
Get-StringProperty $SourceString "-intftype"

"Xen

Solving issue #2 is a little more involved as we need find the quoted text, escape it and then restore it when needed. The following Get-StringProperty advanced function will first replace all quoted spaces with “^” and then replace the quotes themselves with “^^”. This permits the split function to work as expected. Finally, everything is put back together just before we need it!

<#
.SYNOPSIS
   Get a named property value from a string.
.DESCRIPTION
   Returns a case-insensitive property from a string, assuming the property is
   named before the actual property value and is separated by a space. For
   example, if the specified SearchString contained "-property1 <value1>
   -property2 <value2>”, searching for "-Property1" would return "<value1>".
.PARAMETER SearchString
   String to search for the specified property name.
.PARAMETER PropertyName
   The property name to search the SearchString for.
.PARAMETER Default
   If the property is not found returns the specified string. This parameter is
   optional and if not specified returns $null (by default) if the property is
   not found.
.EXAMPLE
   Get-StringProperty -SearchString $StringToSearch -PropertyName "-property1"

   This command searches the $StringToSearch variable for the presence of the property
   "-property1" and returns its value, if found. If the property name is not found,
   the default $null will be returned.
.EXAMPLE
   Get-StringProperty $StringToSearch "-property3" "Not found"

   This command searches the $StringToSearch variable for the presence of the property
   "-property3" and returns its value, if found. If the property name is not found,
   the "Not Found" string will be returned.
.NOTES
   Author - Iain Brighton - @iainbrighton, iain.brighton@virtualengine.co.uk
#>
function Get-StringProperty {

    [CmdletBinding(HelpUri = 'https://virtualengine.co.uk/2014/searching-for-string-properties-with-powershell/')]
    [OutputType([String])]
    Param
    (
        [Parameter(Mandatory=$true, ValueFromPipeline=$true, ValueFromPipelineByPropertyName=$true, Position=0)]
        [ValidateNotNullOrEmpty()]
        [Alias("Search")]
        [string]
        $SearchString,

        [Parameter(Mandatory=$true, ValueFromPipelineByPropertyName=$true, Position=1)]
        [ValidateNotNullOrEmpty()]
        [Alias("Name","Property")]
        [string]
        $PropertyName,

        [Parameter(ValueFromPipelineByPropertyName=$true, Position=2)]
        [AllowNull()]
        [String]
        $Default = $null
    )

    Begin { }

    Process {
        # Locate and replace quotes with '^^' and quoted spaces '^' to aid with parsing, until there are none left
        while ($SearchString.Contains('"')) {
            # Store the right-hand side temporarily, skipping the first quote
            $searchStringRight = $SearchString.Substring($SearchString.IndexOf('"') +1);
            # Extract the quoted text from the original string
            $quotedString = $SearchString.Substring($SearchString.IndexOf('"'), $searchStringRight.IndexOf('"') +2);
            # Replace the quoted text, replacing spaces with '^' and quotes with '^^'
            $SearchString = $SearchString.Replace($quotedString, $quotedString.Replace(" ", "^").Replace('"', "^^"));
        }

        # Split the $SearchString based on one or more blank spaces
        $stringComponents = $SearchString.Split(' +',[StringSplitOptions]'RemoveEmptyEntries'); 
        for ($i = 0; $i -le $stringComponents.Length; $i++) {
            # The standard Powershell CompareTo method is case-sensitive
            if ([string]::Compare($stringComponents[$i], $PropertyName, $True) -eq 0) {
                # Check that we're not over the array boundary
                if ($i+1 -le $stringComponents.Length) {
                    # Restore any escaped quotation marks and spaces
                    # If you wanted to trim quotation marks you could use this instead:
                    #  return $stringComponents[$i+1].Replace("^^", '"').Replace("^", " ").Trim('"');
                    return $stringComponents[$i+1].Replace("^^", '"').Replace("^", " ");
                }
            }
        }
        # If nothing has been found or we're over the array boundary, return the default value
        return $Default;
    }

    End { }
}

$SourceString = 'set interface 1/3 -ifAlias DMZ -throughput 0 -bandwidthHigh 0 -bandwidthNormal 0 -intftype "Xen Virtual" -ifnum 1/3';
Get-StringProperty $SourceString "-intftype"

"Xen Virtual"

That’s much better Smile. Just for good luck I have also created a complimentary Test-StringProperty advanced function that tests whether a property name is present or not. This removes a lot of if ((Get-StringPropertyValue $SearchString “PropertyName”) -ne $null) { Do-Something } calls.

<#
.SYNOPSIS
   Test for a named property value in a string.
.DESCRIPTION
   Tests for the presence of a property value in a string and returns a boolean
   value. For example, if the specified SearchString contained "-property1
   -property2 <value2>”, searching for "-Property1" or "-Property2" would return
   $true, but searching for "-Property3" would return $false
.PARAMETER SearchString
   String to search for the specified property name.
.PARAMETER PropertyName
   The property name to search the SearchString for.
.EXAMPLE
   Test-StringProperty -SearchString $StringToSearch -PropertyName "-property1"

   This command searches the $StringToSearch variable for the presence of the property
   "-property1". If the property name is found it returns $true. If the property name
   is not found, it will return $false.
.NOTES
   Author - Iain Brighton - @iainbrighton, iain.brighton@virtualengine.co.uk
#>
function Test-StringProperty {

    [CmdletBinding(HelpUri = 'https://virtualengine.co.uk/2014/searching-for-string-properties-with-powershell/')]
    [OutputType([bool])]
    Param
    (
        [Parameter(Mandatory=$true, ValueFromPipeline=$true, ValueFromPipelineByPropertyName=$true, Position=0)]
        [ValidateNotNullOrEmpty()]
        [Alias("Search")]
        [string]
        $SearchString,

        [Parameter(Mandatory=$true, ValueFromPipelineByPropertyName=$true, Position=1)]
        [ValidateNotNullOrEmpty()]
        [Alias("Name","Property")]
        [string]
        $PropertyName
    )

    Begin { }

    Process {
        # Split the $SearchString based on one or more blank spaces
        $stringComponents = $SearchString.Split(' +',[StringSplitOptions]'RemoveEmptyEntries'); 
        for ($i = 0; $i -le $stringComponents.Length; $i++) {
            # The standard Powershell CompareTo method is case-sensitive
            if ([string]::Compare($stringComponents[$i], $PropertyName, $True) -eq 0) { return $true; }
        }
        # If nothing has been found or we're over the array boundary, return the default value
        return $false;
    }

    End { }
}

Some final words of warning:

  • If there are escaped (double) quotes then this function won’t work without some modification.
  • If the $SearchString just so happens to natively contain either “^” and/or “^^” it won’t (currently) work either.
  • If you have any improvements or feedback then please let me know.
  • Please test in your environment before putting any 3rd party or external code into production!

Querying .APPV Package Properties Part 2

powershell_appv_logoFollowing on from Part 1, we can also query the properties of an App-V 5.0 .APPV package with another cmdlet included in the Virtual Engine App-V 5.0 .APPV PowerShell CmdLets; Get-AppV5FilePackage.  The main difference with this cmdlet (when compared to Get-AppV5FileXml and Get-AppV5FileXmlPackage) is that it returns a custom PSObject with a simpler property namespace and additional package information.

To populate our object we can run this:

[code]C:\PS> $AppVPackage = Get-AppV5FilePackage –AppV C:\Mozilla_Firefox_v17.0.appv[/code]

Going back to our example in Part 1, we can then query this object for both the VersionId and PVAD like so:

[code]C:\PS> $AppVPackage | Select-Object VersionId,FileSystemRoot | Format-List

VersionId      : fd215f39-f317-447d-aa79-7fe6c35e73f6
FileSystemRoot : C:\Program Files\Mozilla Firefox[/code]

Our custom PSObject also includes details of all the files in package and the uncompressed size. If you want to know how much space a package will take when loaded into the App-V 5 client cache, then this is the command for you!

To return the uncompressed package size we could run:

[code]$AppVPackage.UncompressedSize
43738570[/code]

If you want this in MB, easy:

[code]C:\PS> ($AppVPackage.UncompressedSize/1MB).ToString(“N2”)
41.71[/code]

Want the total number of files in the package?

[code]C:\PS> $AppVPackage.Files.Count
85[/code]

Need a list of all the files in the .APPV package?

[code]C:\PS> $AppVPackage.Files | Select-Object FullName

FullName
——–
Registry.dat
Root/components/binary.manifest
Root/components/browsercomps.dll
Root/defaults/pref/channel-prefs.js
Root/defaults/pref/local-settings.js
Root/dictionaries/en-US.aff
Root/dictionaries/en-US.dic
Root/extensions/%7B972ce4c6-7e08-4474-a285-3208198ce6fd%7D/icon.png
Root/extensions/%7B972ce4c6-7e08-4474-a285-3208198ce6fd%7D/install.rdf
Root/modules/services-aitc/.mkdir.done
Root/modules/services-common/.mkdir.done
Root/modules/services-crypto/.mkdir.done
Root/modules/services-notifications/.mkdir.done
Root/modules/services-sync/.mkdir.done
Root/modules/services-sync/engines/.mkdir.done
Root/modules/sessionstore/.mkdir.done
Root/searchplugins/amazondotcom.xml
Root/searchplugins/bing.xml
Root/searchplugins/eBay.xml
Root/searchplugins/google.xml
Root/searchplugins/twitter.xml
Root/searchplugins/wikipedia.xml
Root/searchplugins/yahoo.xml
Root/webapprt/omni.ja
Root/webapprt/webapprt.ini
Root/override.ini
Root/mozilla.cfg
Root/install.log
Root/AccessibleMarshal.dll
Root/application.ini
Root/blocklist.xml
Root/breakpadinjector.dll
Root/chrome.manifest
Root/crashreporter-override.ini
Root/crashreporter.exe
Root/crashreporter.ini
Root/D3DCompiler_43.dll
Root/d3dx9_43.dll
Root/dependentlibs.list
Root/firefox.exe
Root/freebl3.chk
Root/freebl3.dll
Root/gkmedias.dll
Root/libEGL.dll
Root/libGLESv2.dll
Root/maintenanceservice.exe
Root/maintenanceservice_installer.exe
Root/mozalloc.dll
Root/mozglue.dll
Root/mozjs.dll
Root/mozsqlite3.dll
Root/msvcp100.dll
Root/msvcr100.dll
Root/nspr4.dll
Root/nss3.dll
Root/nssckbi.dll
Root/nssdbm3.chk
Root/nssdbm3.dll
Root/nssutil3.dll
Root/omni.ja
Root/platform.ini
Root/plc4.dll
Root/plds4.dll
Root/plugin-container.exe
Root/precomplete
Root/removed-files
Root/smime3.dll
Root/softokn3.chk
Root/softokn3.dll
Root/ssl3.dll
Root/update-settings.ini
Root/updater.exe
Root/updater.ini
Root/webapp-uninstaller.exe
Root/webapprt-stub.exe
Root/xpcom.dll
Root/xul.dll
Root/firefox.exe.0.ico
Root/firefox.exe.1.ico
FilesystemMetadata.xml
StreamMap.xml
PackageHistory.xml
AppxManifest.xml
AppxBlockMap.xml
[Content_Types].xml[/code]

Hopefully, these cmdlets will be useful? As an example, we’ve used the Get-AppV5FilePackage cmdlet to create HTML .APPV package reports. Happy PoShing Open-mouthed smile

Querying .APPV Package Properties Part 1

powershell_appv_logoSo you have an App-V 5.0 package (in this example I’ll use Mozilla Firefox) and you’d like to know various properties about the package without loading it into the App-V 5 client. The sequencer creates numerous files by default that expose some extremely pertinent information, i.e. the PackageId. The PackageId is specified in both the template <PackageName>_DeploymentConfig.xml and <PackageName>_UserConfig.xml files.

Unfortunately for us, other useful details such as the VersionId and Primary Virtual Asset Directory (PVAD) are stored within the <PackageName>.appv file. This is a compressed archive and can simply be opened with Windows Explorer by renaming the file with a .ZIP extension. Within this file are some more files (generated by the Sequencer):

image

If we want to find out information such as the VersionId or Primary Virtual Asset Directory (PVAD directory) without loading the package into the App-V 5.0 client, we have to manually rename the file, open the archive and inspect the various files (AppxManifest.xml for the VersionId and FileSystemMetadata.xml for the PVAD). Whilst this is fine, it is manual and if we forget to rename the file back to a .APPV file we leave the package unusable!

Using PowerShell

This is what the Virtual Engine App-V 5.0 Package PowerShell CmdLets are for! Included are three CmdLets that can help us here: Get-AppV5FileXmlGet-AppV5FileXmlPackage and/or Get-AppV5FilePackage. This post covers the first two and Part 2 of this post covers the Get-AppV5FilePackage command.

Get-AppV5FileXml

This cmdlet will extract the contents of a single XML file within an .APPV package. The results are returned as a System.Xml.XmlDocument that we can then interrogate. No more manually renaming the archive, extracting and then loading the file.

Going back to our example, if we wanted the VersionId of an .APPV package we need to interrogate the AppxManifest.xml file. To accomplish this we can run this command (all on a single line!):

[code]C:\PS> (Get-AppV5FileXml –AppV C:\Mozilla_Firefox_v17.0.appv –XML AppxManifest).Package.Identity.VersionId

fd215f39-f317-447d-aa79-7fe6c35e73f6

C:\PS>[/code]

If we want the PVAD directory, this is stored in the FileSystemMetadata.xml file. Easy (again, all on a single line!):

[code](Get-AppV5FileXml –AppV C:\Mozilla_Firefox_v17.0.appv –XML FilesystemMetadata).Metadata.FileSystem.Root

C:\Program Files\Mozilla Firefox

C:\PS>[/code]

This method loads each file into it’s own XmlDocument. Is there a way to load all the XML properties at once? You betcha!

Get-AppV5FileXmlPackage

This command bundles all the default sequencer generated XML files into a single XmlDocument object. This includes the contents of the AppxManifest.xml, StreamMap.xml, AppxBloackMap.xml, PackageHistory.xml and FileSystemMetadata.xml files. Each XML file is loaded under the <AppV5> element within the XML document and therefore, the paths are extended slightly.

Taking our example, if want to get the VersionId and PVAD we can do this instead:

[code]C:\PS> $Mozilla = Get-AppV5FileXmlPackage –AppV C:\Mozilla_Firefox_v17.0.appv

C:\PS> $Mozilla.AppV5.Package.Identity.VersionId
fd215f39-f317-447d-aa79-7fe6c35e73f6

C:\PS> $Mozilla.AppV5.Metadata.FileSystem.Root
C:\Program Files\Mozilla Firefox

C:\PS>[/code]

In Part 2, we can use yet another method: Get-AppV5FilePackage. Happy PowerShelling Open-mouthed smile

Updating and Writing XML Files with PowerShell

Manipulating XML files with PowerShell is something that we’re having to accomplish more and more internally. Microsoft App-V 5.0 and RES Workspace Manager utilise XML files extensively. Whilst we can manually tweak them, there’s nothing like automating things for consistency and speed!

I have seen a lot of “rip and replace” and “find and replace” script examples, but ensuring that the correct elements/nodes are added in the correct place can be troublesome. The only real option is to utilise the built-in .Net Framework XML objects. Hopefully this post will lay the basis for some more App-V 5.0 focused blogs in the future Open-mouthed smile.

There are lots of forum posts out there that detail snippets of information, but a lot of it is trial and error. As a starting point you might want to read up here first. Reading XML files is one thing, but as the previous article mentions, inserting (multiple) elements can be quite convoluted. Note: I’m using the XmlDocument .Net object here but it is possible to utilise other .Net classes, e.g. XPath and/or XDocument.

Here is the example we’ll use in this post. Apologies; the XML formatting is stripped by the plugin:

<!-- employees.xml -->
<employees>
  <employee id="101">
    <name>Frankie Johnny</name>
    <age>36</age>
  </employee>
  <employee id="102">
    <name>Elvis Presley</name>
    <age>79</age>
  </employee>
  <employee id="301">
    <name>Ella Fitzgerald</name>
    <age>102</age>
  </employee>
</employees>

Creating XML Elements

If we wish to add a new employee record to the above document, we need to create a new XML element (or node). Firstly we need to load the XML document:

$fileName = “employees.xml”;
$xmlDoc = [System.Xml.XmlDocument](Get-Content $fileName);

Now we can create our new element/node, append it to the parent reference and save it:

$newXmlEmployeeElement = $xmlDoc.CreateElement("employee");
$newXmlEmployee = $xmlDoc.employees.AppendChild($newXmlEmployeeElement);
$xmlDoc.Save($fileName);

To shorten this we can just use this (see here for more information on the CreateElement method):

$newXmlEmployee = $xmlDoc.employees.AppendChild($xmlDoc.CreateElement("employee"));
$xmlDoc.Save($fileName);

If we examine the resulting XML file we’ll find the following (note the new empty element):

<!-- employees.xml -->
<employees>
  <employee id="101">
    <name>Frankie Johnny</name>
    <age>36</age>
  </employee>
  <employee id="102">
    <name>Elvis Presley</name>
    <age>79</age>
  </employee>
  <employee id="301">
    <name>Ella Fitzgerald</name>
    <age>102</age>
  </employee>
  <employee />
</employees>

 Adding XML Attributes

To add the ID attribute tag in the XML document, we need to create a new XmlDocument Attribute and then attach it to our newly created element. We can create an XmlDocument attribute on our element with the following code:

$newXmlEmployee = $xmlDoc.employees.AppendChild($xmlDoc.CreateElement("employee"));
$newXmlEmployee.SetAttribute(“id”,”111”);
$xmlDoc.Save($fileName);

Now our resulting XML file looks like this:

<!-- employees.xml -->
<employees>
  <employee id="101">
    <name>Frankie Johnny</name>
    <age>36</age>
  </employee>
  <employee id="102">
    <name>Elvis Presley</name>
    <age>79</age>
  </employee>
  <employee id="301">
    <name>Ella Fitzgerald</name>
    <age>102</age>
  </employee>
  <employee id="111">
</employees>[/code]

Adding Nested XML Elements

Knowing how to add elements we can simply create our sub elements and attach them to our newly created parent reference:

$newXmlNameElement = $newXmlEmployee.AppendChild($xmlDoc.CreateElement("name"));
$newXmlAgeElement = $newXmlEmployee.AppendChild($xmlDoc.CreateElement("age"));

The resulting XML file now looks like:

<!-- employees.xml -->
<employees>
  <employee id="101">
    <name>Frankie Johnny</name>
    <age>36</age>
  </employee>
  <employee id="102">
    <name>Elvis Presley</name>
    <age>79</age>
  </employee>
  <employee id="301">
    <name>Ella Fitzgerald</name>
    <age>102</age>
  </employee>
  <employee id="111">
    <name />
    <age />
  </employee>
</employees>

Adding XML Text Nodes

Unbeknownst to me, when you have text within an element tag, i.e. <name>Iain Brighton</name>, this is known as a text node (at least in XDocument speak). This is probably the bit that took longest to work out.

To add the text node we can use the following code:

$newXmlNameElement = $newXmlEmployee.AppendChild($xmlDoc.CreateElement("name"));
$newXmlNameTextNode = $newXmlNameElement.AppendChild($xmlDoc.CreateTextNode("Iain Brighton"));

$newXmlAgeElement = $newXmlEmployee.AppendChild($xmlDoc.CreateElement("age"));
$newXmlAgeTextNode = $newXmlAgeElement.AppendChild($xmlDoc.CreateTextNode("37"));

Et voilà!

<!-- employees.xml -->
<employees>
  <employee id="101">
    <name>Frankie Johnny</name>
    <age>36</age>
  </employee>
  <employee id="102">
    <name>Elvis Presley</name>
    <age>79</age>
  </employee>
  <employee id="301">
    <name>Ella Fitzgerald</name>
    <age>102</age>
  </employee>
  <employee id="111">
    <name>Iain Brighton</name>
    <age>37</age>
  </employee>
</employees>

 Full PowerShell Code Snippet

Here is the full code listing:

$fileName = “employees.xml”;
$xmlDoc = [System.Xml.XmlDocument](Get-Content $fileName);

$newXmlEmployee = $xmlDoc.employees.AppendChild($xmlDoc.CreateElement("employee"));
$newXmlEmployee.SetAttribute("id","111");

$newXmlNameElement = $newXmlEmployee.AppendChild($xmlDoc.CreateElement("name"));
$newXmlNameTextNode = $newXmlNameElement.AppendChild($xmlDoc.CreateTextNode("Iain Brighton"));

$newXmlAgeElement = $newXmlEmployee.AppendChild($xmlDoc.CreateElement("age"));
$newXmlAgeTextNode = $newXmlAgeElement.AppendChild($xmlDoc.CreateTextNode("37"));

$xmlDoc.Save($fileName);

Documenting App-V 5.0 Packages

Continuing our series of posts on the Virtual Engine App-V 5.0 .APPV PowerShell CmdLets, this one will show you how to quickly document your .APPV packages’ contents. The Save-AppV5FileReport cmdlet generates a HTML report of information contained within the .APPV package contents. Here’s an example summary report (detailed reports are also available):

Generating Single Reports

Generating summary reports (such as the one above) simply requires running the following command:

[code]C:\PS> Save-AppV5FileReport –AppV C:\Mozilla_Firefox_v17.0\Mozilla_Firefox_v17.0.appv[/code]

This will generate a Mozilla_Firefox_V17.0_Report.html file in the source C:\Mozilla_Firefox_v17.0\ directory. If we wanted a detailed report instead we could run:

[code]C:\PS> Save-AppV5FileReport –AppV C:\Mozilla_Firefox_v17.0\Mozilla_Firefox_v17.0.appv –Detailed[/code]

Documenting Multiple Packages

Creating a report for a single package is fine, but what if we wanted to document all packages on a share or in a folder? Fortunately this is simple. If you wanted each package report in the source package directory, we can do so like this:

[code]C:\PS> Get-ChildItem C:\Packages\ -Include *.appv -Recurse | % { Save-AppV5FileReport -AppV $_.FullName }

Directory: C:\Packages\GoogleChrome_v23.0.1271.91

Mode                LastWriteTime     Length Name
—-                ————-     —— —-
-a—        02/05/2013     12:36       8884 GoogleChrome_v23.0.1271.91_Report.html

Directory: C:\Packages\Mozilla_Firefox_v17.0

Mode                LastWriteTime     Length Name
—-                ————-     —— —-
-a—        02/05/2013     12:36       8802 Mozilla_Firefox_v17.0_Report.html

Directory: C:\Packages\Paint.Net_v3.5.10

Mode                LastWriteTime     Length Name
—-                ————-     —— —-
-a—        02/05/2013     12:36       8770 Paint.NET_v3.5.10_Report.html[/code]

What about if we wanted all the reports in a single location? Again, pretty straight forward. The Save-AppV5FileReport cmdlet has a –FilePath parameter that we can specify the output location. Note: this folder is not automatically created so make sure it exists! Our command to achieve this is:

[code]C:\PS> Get-ChildItem C:\Packages\ -Include *.appv -Recurse | % { Save-AppV5FileReport -AppV $_.FullName -FilePath C:\Packages\Reports\ }

Directory: C:\Packages\Reports

Mode                LastWriteTime     Length Name
—-                ————-     —— —-
-a—        02/05/2013     12:40       8884 GoogleChrome_v23.0.1271.91_Report.html
-a—        02/05/2013     12:40       8802 Mozilla_Firefox_v17.0_Report.html
-a—        02/05/2013     12:40       8770 Paint.NET_v3.5.10_Report.html[/code]

Hopefully you’ll find these HTML reports useful. We are planning to add sequencer reports into this module in the near future too, so stay tuned! Happy PoShing 😀

Installing the new App-V 5 PowerShell Modules

Following on from the release of the Virtual Engine App-V 5.0 PowerShell CmdLets, I thought I best give you a quick run through on setting them up! Installing the PowerShell modules is easy, regardless of whether you’re installing them just for your user account or on a per machine basis. Note: we will package these up into a .MSI once we’ve had some initial feedback and fixed any “features!”

Per User Installation

To install the new modules on a per-user basis, extract the files into your ‘Documents\WindowsPowerShell\Modules\’ directory:

image

Per Computer Installation

To install the new modules on a per-computer basis, extract the files into the ‘C:\Windows\System32\WindowsPowerShell\v1.0\Modules\’ (or %PSModulePath% for short!) directory:

image

Importing

To import and use the modules, simply run the following PowerShell command (they are digitally signed):

[code]Import-Module VirtualEngine-AppV5[/code]

You may be prompted to confirm that you trust the publisher  (!?). The modules are digitally signed. If you want to use them, then you better ensure that you select the ‘Always Run’ (or at lease ‘Run Once’) option!

PowerShell_Trusted_Publisher

Once they’re loaded you can check by running the following PowerShell command. If you see something similar to this then you should be all set:

[code]PS C:\Windows\system32> Get-Command -Module VirtualEngine*

CommandType     Name                                      ModuleName
———–     —-                                      ———-
Function        Get-AppV5File                             VirtualEngine-AppV5
Function        Get-AppV5FilePackage                      VirtualEngine-AppV5
Function        Get-AppV5FileReport                       VirtualEngine-AppV5
Function        Get-AppV5FileXml                          VirtualEngine-AppV5
Function        Get-AppV5FileXmlPackage                   VirtualEngine-AppV5
Function        Get-VEAppV5Version                        VirtualEngine-AppV5
Function        Save-AppV5File                            VirtualEngine-AppV5
Function        Save-AppV5FileReport                      VirtualEngine-AppV5
Function        Save-AppV5FileXml                         VirtualEngine-AppV5
Function        Save-AppV5FileXmlPackage                  VirtualEngine-AppV5

PS C:\Windows\system32> [/code]

Note: The modules require PowerShell 3.0 and the Microsoft .Net Framework 4.5. These are installed by default on Windows 8 and Windows Server 2012. If you’re running Windows 7 without these requirements, then you’ll receive errors. Make sure you meet these requirements.

Extracting files from an .APPV file with PowerShell

Whilst doing a lot of work with App-V 5.0 we have come across the requirement to look inside the .appv file with PowerShell. In our particular instance we’re after the package VersionId which is contained in the AppxManifest.xml file. As previously championed, we love automating and this should be easy!

The .appv file extension is a compressed archive and therefore, should be simple to crack open. After scouring the interweb, there is very little information on how to achieve this in code. We could use the built-in Shell32.dll functionality but this requires us to rename the file to .zip first. Ideally we want to avoid copying or renaming the source files. I did find one reference over on the Login Consultants forum which pointed me in the right direction.

Disclaimer: the following code requires the .Net Framework 4.5. The System.IO.Compression.FileSystem object is not available in previous releases. You can check in the C:\Windows\Microsoft.NET\assembly\GAC_MSIL\ folder and if you have the System.IO.Compression.FileSystem folder you should be good to go Smile with tongue out.

To get this new .Net functionality to work within PowerShell we will be calling the .Net assemblies directly and therefore need to create a couple of references. In our example we’ll be using both the System.IO.Compression and System.IO.Compression.FileSystem assemblies (two different DLLs hence the two references):

[code]### The System.IO.Compression.FileSystem requires at least .Net Framework 4.5
[System.Reflection.Assembly]::LoadWithPartialName(“System.IO.Compression”) | Out-Null;
[System.Reflection.Assembly]::LoadWithPartialName(“System.IO.Compression.FileSystem”) | Out-Null;[/code]

Next we can create our FileStream object (with read only access) required by the ZipArchive object class.

[code]### Open the ZipArchive with read access
$FileStream = New-Object System.IO.FileStream($SourceAppV5Archive, [System.IO.FileMode]::Open);
$AppV5Archive = New-Object System.IO.Compression.ZipArchive($FileStream);[/code]

In fact we can shorten this down to a single line:

[code]### Open the ZipArchive with read access
$AppV5Archive = New-Object System.IO.Compression.ZipArchive(New-Object System.IO.FileStream($SourceAppV, [System.IO.FileMode]::Open));[/code]

Once we have opened our .ZIP (.appv) file we can retrieve the AppXManifest.xml file entry:

[code]### Locate the AppxManifest.xml file
$AppxManifestEntry = $AppV5Archive.GetEntry(“AppxManifest.xml”);[/code]

Having the ZipArchiveEntry object we can extract it with the ExtractToFile method:

[code]### Extract the $ZipArchiveEntry
$ZipArchiveEntry.ExtractToFile($SaveAs);[/code]

Unfortunately this does work and reports the following error:

[code]Method invocation failed because [System.IO.Compression.ZipArchiveEntry] doesn’t contain a method named ‘ExtractToFile’.[/code]

Eh!? WT… Looking on the ZipArchiveEntry reference page on MSDN, the ExtractToFile is an Extension Method. Therefore, we need to utilise the underlying object method, the ZipFileExtensions.ExtractToFile method. For more information on Extension Methods in PowerShell see here and here. Now our code should look like this:

[code]### Extract the ZipArchiveEntry (ZipArchiveEntry.ExtractToFile is an extension method)
[System.IO.Compression.ZipFileExtensions]::ExtractToFile($AppxManifestEntry, $SaveAs, $Overwrite);[/code]

Finally we need to ensure that we correctly dispose of the ZipArchive object otherwise we’ll leave it open:

[code]### Ensure we close the file handle otherwise the file will be left open
$AppV5Archive.Dispose();[/code]

That’s it! It you want a simpler way of doing this, just download the Virtual Engine App-V 5.0 Package PowerShell CmdLets. You can achieve all this in just a single command:

[code]Save-AppV5FileXml -AppV c:\package.appv -XML AppxManifest[/code]

Full PowerShell Code Snippet

Here is the full code listing:

[code]### The System.IO.Compression.FileSystem requires at least .Net Framework 4.5
[System.Reflection.Assembly]::LoadWithPartialName(“System.IO.Compression”) | Out-Null;
[System.Reflection.Assembly]::LoadWithPartialName(“System.IO.Compression.FileSystem”) | Out-Null;

### Open the ZipArchive with read access
$AppV5Archive = New-Object System.IO.Compression.ZipArchive(New-Object System.IO.FileStream($SourceAppV5Archive, [System.IO.FileMode]::Open));

### Locate the AppxManifest.xml file
$AppxManifestEntry = $AppV5Archive.GetEntry(“AppxManifest.xml”);
### ZipArchiveEntry.ExtractToFile is an extension method
[System.IO.Compression.ZipFileExtensions]::ExtractToFile($AppxManifestEntry, $SaveAs, $true);

### Ensure we close the file handle otherwise the file will be left open
$AppV5Archive.Dispose();[/code]