App-V 5 with Excel Automation Addins and RunVirtual

This blog entry discusses how we can use App-V 5, Connection Groups and RunVirtual to present Excel automation addins to end users.

Microsoft Excel addins come in two forms – either an automation addin or a Component Object Model (COM) addin.

From an App-V perspective, capturing a COM addin is a relatively trivial process since they are registered using a static registry value – namely a ProgId in the following registry location:

HKEY_CURRENT_USER\Software\Microsoft\Office\Excel\Addins\

Automation addins however, work in a different way. When they are registered in the Windows registry side-by-die with other automation addins, they create a dynamically enumerated OPEN{x} key in the following registry location:

HKEY_CURRENT_USER\Software\Microsoft\Office\{Version}\Excel\Options

For example:

OPEN      C:\alkane\addin1.xla
OPEN1     C:\alkane\addin2.xla
OPEN2     C:\alkane\addin3.xla

This obviously creates a bit of a headache when capturing an automation addin with any packaging toolset.  Put simply, if we captured automation addin 1 on a clean virtual machine it would register under the following registry value:

HKEY_CURRENT_USER\Software\Microsoft\Office\{Version}\Excel\Options\OPEN

and if we captured addin 2 on a clean virtual machine it would also register under the same registry value:

HKEY_CURRENT_USER\Software\Microsoft\Office\{Version}\Excel\Options\OPEN

So if they were both installed (for thick installations) or streamed (App-V in a connection group) to the same machine, each package would conflict and you would only see the ‘last’ addin.

From an App-V perspective, this isn’t too bad if you are using the ugly ‘App-V 4’ method of providing Excel addins by isolating them as separate packages; by this, I mean creating package 1 with a shortcut called “Excel with Addin 1” and package 2 with a shortcut called “Excel with Addin 2” (having said that, you may have issues seeing native Excel addins at the same time). But users don’t like this clunky approach. They expect to launch their local instance of Excel and see all of the required addins side by side.  And to achieve this optimal user experience you would need to use RunVirtual to present your Excel addins with connection groups.

I should note too, that removing automation isn’t trivial either, since the OPEN{x}registry values must stay in sequential order.  If we installed 3 addins:

OPEN      C:\alkane\addin1.xla
OPEN1     C:\alkane\addin2.xla
OPEN2     C:\alkane\addin3.xla

and then removed addin2.xla so it became this:

OPEN      C:\alkane\addin1.xla
OPEN2     C:\alkane\addin3.xla

It would break things because OPEN1 is missing. Instead it would need refactoring to:

OPEN      C:\alkane\addin1.xla
OPEN1     C:\alkane\addin3.xla

Luckily, rather than scripting this logic the Excel automation object (Excel.Application) does all this for us.  And we can dynamically configure our Excel addins using PowerShell.  A few things to note:

  • Before we create an instance of Excel.Application, we disable RunVirtual.  Why?  Because instantiating Excel.Application spawns an Excel.exe process, which in turn kicks in RunVirtual and any associated packages!  If you’re using my aforementioned approach to present Excel addins using RunVirtual this could create a world of pain where ultimately Excel.exe gets so confused that it times out!   Of course, we re-enable RunVirtual at the end.
  • It creates a log file in the %temp% folder so you can see what’s happening.  Rename the log file as required on line 1.
  • You will need to save this script as ‘addins.ps1’ and lump it in the Scripts folder inside your App-V package.
$logfile = "$($env:temp)\your_log_name.log"

function Write-Log {
    Param($message)
    $datetime = Get-Date -Format "dd/MM/yyyy HH:mm:ss"
    Add-Content $logfile "$($datetime): $message"
}

function Disable-Excel-Runvirtual {
	if (Test-Path HKCU:\SOFTWARE\Microsoft\AppV\Client\RunVirtual\Excel.exe) {
		Write-Log ('Disabling RunVirtual for Excel.exe (if configured)')
		Rename-Item HKCU:\SOFTWARE\Microsoft\AppV\Client\RunVirtual\Excel.exe -NewName Excel.exe.disable
	}
}

function Enable-Excel-Runvirtual {
	if (Test-Path HKCU:\SOFTWARE\Microsoft\AppV\Client\RunVirtual\Excel.exe.disable) {
		Write-Log ('Enabling RunVirtual for Excel.exe (if configured)')
		Rename-Item HKCU:\SOFTWARE\Microsoft\AppV\Client\RunVirtual\Excel.exe.disable -NewName Excel.exe
	}
}

function Get-Current-Script-Directory {
	$currentDirectory = [System.AppDomain]::CurrentDomain.BaseDirectory.TrimEnd('\') 
	if ($currentDirectory -eq $PSHOME.TrimEnd('\')) 
	{     
		$currentDirectory = $PSScriptRoot 
	}
	Write-Log ('Current script directory is: ' + $currentDirectory)
	return $currentDirectory
}

function Delete-AddInRegistry {
    Param(  
    [string]$AppVCurrentUserSID,
    [string]$ExcelVersion,
    [string]$AppVAddinPath,
	[string]$AppVPackageId,
	[string]$AppVVersionId
    )  

    #when an addin is uninstalled, it automatically creates a registry entry in the 'add-in manager' key.  We must delete it.

    #remove registry for this package if exists
    $registrykey = "HKCU:\Software\Microsoft\Office\$ExcelVersion\Excel\Add-in Manager"
    Write-Log ("Deleting registry for this package (if exists): " + $registrykey + " " + $AppVAddinPath)
    Remove-ItemProperty -path $registrykey -name $AppVAddinPath -Force -ErrorAction SilentlyContinue       
	  
	#Also ensure registry for the addin itself is removed
	$registrykey = "HKCU:\Software\Microsoft\Office\14.0\Excel\Options"
	$RegKey = (Get-ItemProperty $registrykey)
	$RegKey.PSObject.Properties | ForEach-Object {
	  If($_.Value -like "*$AppVAddinPath*"){
		Write-Log ("Deleting registry for this package: " + $registrykey + " " + $_.Name)
		Remove-ItemProperty -path $registrykey -name $_.Name -Force -ErrorAction SilentlyContinue  
	  }
	}       
}

function Install-Addin()
{
    Param(
        [String]$AppVAddinPath
    )

	$ExitCode = 1
    $AppVPackageId = ""
    $AppVVersionId = ""
    $ExcelVersion = ""
	$AppVCurrentUserSID = ([System.Security.Principal.WindowsIdentity]::GetCurrent()).User.Value	

	Write-Log ('Installing: ' + $AppVAddinPath)
	
	#If RunVirtual is configured for Excel.exe it may cause issues with COM automation, so we disable it and re-enable it later
	Disable-Excel-Runvirtual
	
	$CurrentScriptDirectory = Get-Current-Script-Directory
	
    if (Test-Path $CurrentScriptDirectory) {
	    $AppVPackageId = (get-item $CurrentScriptDirectory).parent.parent
        $AppVVersionId = (get-item $CurrentScriptDirectory).parent

        Write-Log ('Package ID is: ' + $AppVPackageId)
        Write-Log ('Version ID is: ' + $AppVVersionId)
    } 
		 
    if (Test-Path -Path $AppVAddinPath -PathType Leaf) {
	
        $Addin = Get-ChildItem -Path $AppVAddinPath
		
        if (('.xla', '.xlam', '.xll') -NotContains $Addin.Extension) {
            Write-Log 'Excel add-in extension not valid'			
        } else {
        
            try {
				
				Write-Log 'Opening reference to Excel'
				 
                $Excel = New-Object -ComObject Excel.Application
				$ExcelVersion = $Excel.Version

                try {
                    $ExcelAddins = $Excel.Addins
                    $ExcelWorkbook = $Excel.Workbooks.Add()
                    $InstalledAddin = $ExcelAddins | ? { $_.Name -eq $Addin.Name }

                    if (!$InstalledAddin) {          
                        $NewAddin = $ExcelAddins.Add($Addin.FullName, $false)
                        $NewAddin.Installed = $true            			
                        Write-Log ('Add-in "' + $Addin.Name + '" successfully installed!')
						$ExitCode = 0
                    } else {        
                        Write-Log ('Add-in "' + $Addin.Name + '" already installed!')  
						$ExitCode = 0
                    }
                } catch {
                    Write-Log 'Could not install the add-in: ' + $_.Exception.Message
                } finally {
					Write-Log 'Closing reference to Excel'
					$ExcelWorkbook.Close($false)
                    $Excel.Quit()
					
                    if ($InstalledAddin -ne $null) {
                        [System.Runtime.Interopservices.Marshal]::FinalReleaseComObject($InstalledAddin) | Out-Null
					}
                    [System.Runtime.Interopservices.Marshal]::FinalReleaseComObject($ExcelWorkbook) | Out-Null
					[System.Runtime.Interopservices.Marshal]::FinalReleaseComObject($ExcelAddins) | Out-Null
					[System.Runtime.Interopservices.Marshal]::FinalReleaseComObject($Excel) | Out-Null
					
                    Remove-Variable InstalledAddin					
					Remove-Variable ExcelWorkbook
					Remove-Variable ExcelAddins
					Remove-Variable Excel
										
					[System.GC]::Collect()
					[System.GC]::WaitForPendingFinalizers()
                }

            } catch {
                Write-Log ('Could not automate Excel add-in: ' + $_.Exception.Message)
            }
        }
    } else {
        Write-Log 'Excel add-in path not found'
    }
    
	Enable-Excel-Runvirtual
	
	exit $ExitCode
}

function Uninstall-Addin()
{
    Param(
        [String]$AppVAddinPath
    )    
 
	$ExitCode = 1
    $AppVPackageId = ""
    $AppVVersionId = ""
    $ExcelVersion = ""
    $AppVCurrentUserSID = ([System.Security.Principal.WindowsIdentity]::GetCurrent()).User.Value

	Write-Log ('Uninstalling: ' + $AppVAddinPath)
	
	#If RunVirtual is configured for Excel.exe it may cause issues with COM automation, so we disable it and re-enable it later
	Disable-Excel-Runvirtual
	 
	$CurrentScriptDirectory = Get-Current-Script-Directory
	 
    if (Test-Path $CurrentScriptDirectory) {
	    $AppVPackageId = (get-item $CurrentScriptDirectory).parent.parent
        $AppVVersionId = (get-item $CurrentScriptDirectory).parent

        Write-Log ('Package ID is: ' + $AppVPackageId)
        Write-Log ('Version ID is: ' + $AppVVersionId)
    }
    
    if (Test-Path -Path $AppVAddinPath -PathType Leaf) {

        $Addin = Get-ChildItem -Path $AppVAddinPath

        if (('.xla', '.xlam', '.xll') -NotContains $Addin.Extension) {
            Write-Log 'Excel add-in extension not valid'			
        } else {

            try {
			
				Write-Log 'Opening reference to Excel'
				
                $Excel = New-Object -ComObject Excel.Application           
				$ExcelVersion = $Excel.Version
				
                try {
                    $ExcelAddins = $Excel.Addins
                    $InstalledAddin = $ExcelAddins | ? { $_.Name -eq $Addin.Name }

                    if (!$InstalledAddin) {                      
                        Write-Log ('Add-in "' + $Addin.Name + '" is not installed!')  
						$ExitCode = 0
                    } else {
                        $InstalledAddin.Installed = $false           			
                        Write-Log ('Add-in "' + $Addin.Name + '" successfully uninstalled!') 
						$ExitCode = 0
                    }
                } catch {
                    Write-Log 'Could not remove the add-in: ' + $_.Exception.Message
                } finally {
                  
           			Write-Log 'Closing reference to Excel'
                    $Excel.Quit()
					
                    if ($InstalledAddin -ne $null) {
                        [System.Runtime.Interopservices.Marshal]::FinalReleaseComObject($InstalledAddin) | Out-Null   
					}
                    [System.Runtime.Interopservices.Marshal]::FinalReleaseComObject($ExcelAddins) | Out-Null    
                    [System.Runtime.Interopservices.Marshal]::FinalReleaseComObject($Excel) | Out-Null                    
					
                    Remove-Variable InstalledAddin
					Remove-Variable ExcelAddins
					Remove-Variable Excel
					
					[System.GC]::Collect()
					[System.GC]::WaitForPendingFinalizers()
					
                    #delete the value from Add-in Manager    
                    Delete-AddInRegistry -ExcelVersion $ExcelVersion -AppVCurrentUserSID $AppVCurrentUserSID -AppVAddinPath $AppVAddinPath -AppVPackageId $AppVPackageId -AppVVersionId $AppVVersionId
                }

            } catch {
                Write-Log ('Could not automate Excel add-in: ' + $_.Exception.Message)
            }
        }
    } else {
        Write-Log 'Excel add-in path not found'       
    }  
	
	Enable-Excel-Runvirtual
	
	exit $ExitCode
}

We run the script in a User context (because it’s writing values to HKCU) at publish time and unpublish time like so.  You will need to change the path to your addin file within the virtual file system and you should be good to go!

 <UserScripts>
      <PublishPackage>
        <Path>powershell.exe</Path>
        <Arguments>-ExecutionPolicy ByPass -WindowStyle Hidden -Command "&amp; { . '[{AppVPackageRoot}]\..\Scripts\addins.ps1'; install-addin -AppVAddinPath '[{AppVPackageRoot}]\QICharts.xla' }"</Arguments>
        <Wait RollbackOnError="true" Timeout="30"/>   
      </PublishPackage>
      <UnpublishPackage>
         <Path>powershell.exe</Path>
         <Arguments>-ExecutionPolicy ByPass -WindowStyle Hidden -Command "&amp; { . '[{AppVPackageRoot}]\..\Scripts\addins.ps1'; uninstall-addin -AppVAddinPath '[{AppVPackageRoot}]\QICharts.xla' }"</Arguments>
         <Wait RollbackOnError="true" Timeout="30"/>
       </UnpublishPackage>
    </UserScripts>

Fonts Not Rendering in App-V 5

I recently sequenced a terminal emulation application that utilised a specific set of fonts for each terminal session, and discovered that the fonts were not rendering correctly in App-V 5. If your fonts don’t render correctly when run inside an App-V 5 package, it may be due to how the fonts have been captured during the sequencing phase.

When i first launched the App-V application shortcut after capturing a default installation, I was presented with random characters and symbols – the fonts hadn’t been successfully virtualised.

I checked the AppxManifest.xml file within my package and saw that the fonts had been captured and processed by the App-V font subsystem like so:

<appv:Extension Category="AppV.Fonts">
	<appv:Fonts>
		<appv:Font Path="[{ProgramFilesX86}]\Persoft\STEssen\Fonts\bsfascii.fon" />
		<appv:Font Path="[{ProgramFilesX86}]\Persoft\STEssen\Fonts\bsfisol1.fon" />
		<appv:Font Path="[{ProgramFilesX86}]\Persoft\STEssen\Fonts\psfascii.ttf" />
		<appv:Font Path="[{ProgramFilesX86}]\Persoft\STEssen\Fonts\psfcrm.ttf" />
		<appv:Font Path="[{ProgramFilesX86}]\Persoft\STEssen\Fonts\psfdgint.ttf" />
		<appv:Font Path="[{ProgramFilesX86}]\Persoft\STEssen\Fonts\Psfdgln.ttf" />
		<appv:Font Path="[{ProgramFilesX86}]\Persoft\STEssen\Fonts\psfdgmos.ttf" />
		<appv:Font Path="[{ProgramFilesX86}]\Persoft\STEssen\Fonts\psfdspec.ttf" />
		<appv:Font Path="[{ProgramFilesX86}]\Persoft\STEssen\Fonts\psfdsupg.ttf" />
		<appv:Font Path="[{ProgramFilesX86}]\Persoft\STEssen\Fonts\psfdtech.ttf" />
		<appv:Font Path="[{ProgramFilesX86}]\Persoft\STEssen\Fonts\psfisol1.ttf" />
		<appv:Font Path="[{ProgramFilesX86}]\Persoft\STEssen\Fonts\psfisol2.ttf" />
		<appv:Font Path="[{ProgramFilesX86}]\Persoft\STEssen\Fonts\psfisol9.ttf" />
		<appv:Font Path="[{ProgramFilesX86}]\Persoft\STEssen\Fonts\Psfkata.ttf" />
		<appv:Font Path="[{ProgramFilesX86}]\Persoft\STEssen\Fonts\Psfpc0__.ttf" />
		<appv:Font Path="[{ProgramFilesX86}]\Persoft\STEssen\Fonts\psfpcasc.ttf" />
		<appv:Font Path="[{ProgramFilesX86}]\Persoft\STEssen\Fonts\psfsymbl.ttf" />
		<appv:Font Path="[{ProgramFilesX86}]\Persoft\STEssen\Fonts\psfwproc.ttf" />
	</appv:Fonts>
</appv:Extension>

Of course, this looked ok to me and the font files did exist inside the VFS at the correct location!

When i took a closer look at how the fonts were installed by the installation routine, I noticed shortcut icons in the C:\Windows\Fonts folder as opposed to physical files:

Fonts in the Application Folder
I wondered if this was the reason why the App-V font subsystem wasn’t correctly processing the font and the fonts were still not rendering correctly in App-V 5!  Since the physical font file didn’t exist inside the Fonts folder itself.

During my next attempt, I lazily tried dragging the fonts into the Fonts folder during the sequencing phase.  I noted again that by default the fonts got copied to the AppData folder and a shortcut was created to the font!  Of course after removing LocalAppData from the exclusions list in App-V, this yielded similar behaviour to my first attempt and the fonts didn’t work in my App-V package!

Fonts Local App Data

My third and final attempt consisted of highlighting all of the font files during the sequencing phase, right-clicking and selecting ‘Install for all users’.

Install Fonts for All UsersYou can see that doing it this way meant that the physical font files were stored in the C:\Windows\Fonts folder directly (no shortcuts!), and the App-V font subsystem then processed them correctly:

Fonts Folder

And the AppxManifest.xml:

<appv:Extension Category="AppV.Fonts">
	<appv:Fonts>
		<appv:Font Path="[{Fonts}]\bsfascii.fon" />
		<appv:Font Path="[{Fonts}]\bsfisol1.fon" />
		<appv:Font Path="[{Fonts}]\psfascii.ttf" />
		<appv:Font Path="[{Fonts}]\psfcrm.ttf" />
		<appv:Font Path="[{Fonts}]\psfdgint.ttf" />
		<appv:Font Path="[{Fonts}]\Psfdgln.ttf" />
		<appv:Font Path="[{Fonts}]\psfdgmos.ttf" />
		<appv:Font Path="[{Fonts}]\psfdspec.ttf" />
		<appv:Font Path="[{Fonts}]\psfdsupg.ttf" />
		<appv:Font Path="[{Fonts}]\psfdtech.ttf" />
		<appv:Font Path="[{Fonts}]\psfisol1.ttf" />
		<appv:Font Path="[{Fonts}]\psfisol2.ttf" />
		<appv:Font Path="[{Fonts}]\psfisol9.ttf" />
		<appv:Font Path="[{Fonts}]\Psfkata.ttf" />
		<appv:Font Path="[{Fonts}]\Psfpc0_.ttf" />
		<appv:Font Path="[{Fonts}]\psfpcasc.ttf" />
		<appv:Font Path="[{Fonts}]\psfsymbl.ttf" />
		<appv:Font Path="[{Fonts}]\psfwproc.ttf" />
	</appv:Fonts>
</appv:Extension>

 

App-V 5 Scripting Context Test

I carried out an App-V 5 scripting context test recently so that I could prove which context the various App-V 5 scripts run in (user or system) when added or published to a machine or user.

App-V 5 Scripting Test Package

I created a very simple App-V 5 package containing Dependency Walker, and added a shortcut to the executable located at [{ProgramFilesX86}]\DependencyWalker\depends.exe.

I also added a script to the Scripts folder called alkane.ps1:

param([string]$message)
add-content "c:\temp\alkane.log" "$message $([bool](([System.Security.Principal.WindowsIdentity]::GetCurrent()).groups -match "S-1-5-32-544")) $([System.Security.Principal.WindowsIdentity]::GetCurrent().Name)" -ErrorAction Stop

It’s a very simple PowerShell script that takes some text as an argument (I pass this to the script so that I know which scripting section it is running from).  Each time the script runs it will output the scripting section is has run from, whether the current scripting context is being run as an administrator, and also what account the script is running under.

In my DeploymentConfig.xml file I added the following UserScripts:

  <UserScripts>

      <StartProcess RunInVirtualEnvironment="true">
        <Path>powershell.exe</Path>
        <Arguments>-ExecutionPolicy ByPass -File "[{AppVPackageRoot}]\..\Scripts\alkane.ps1" -message "DeploymentConfig UserScripts StartProcess"</Arguments>
        <Wait RollbackOnError="true"/>
        <ApplicationId>[{ProgramFilesX86}]\DependencyWalker\depends.exe</ApplicationId>
      </StartProcess>

      <ExitProcess>
        <Path>powershell.exe</Path>
        <Arguments>-ExecutionPolicy ByPass -File "[{AppVPackageRoot}]\..\Scripts\alkane.ps1" -message "DeploymentConfig UserScripts ExitProcess"</Arguments>
        <Wait RollbackOnError="false"/>
        <ApplicationId>[{ProgramFilesX86}]\DependencyWalker\depends.exe</ApplicationId>
      </ExitProcess>
      
      <StartVirtualEnvironment  RunInVirtualEnvironment="true">
        <Path>powershell.exe</Path>
        <Arguments>-ExecutionPolicy ByPass -File "[{AppVPackageRoot}]\..\Scripts\alkane.ps1" -message "DeploymentConfig UserScripts StartVirtualEnvironment"</Arguments>
        <Wait RollbackOnError="true"/>
      </StartVirtualEnvironment>
    
      <TerminateVirtualEnvironment>
        <Path>powershell.exe</Path>
        <Arguments>-ExecutionPolicy ByPass -File "[{AppVPackageRoot}]\..\Scripts\alkane.ps1" -message "DeploymentConfig UserScripts TerminateVirtualEnvironment"</Arguments>
      <Wait RollbackOnError="false"/>
      </TerminateVirtualEnvironment>

      <PublishPackage>
        <Path>powershell.exe</Path>
        <Arguments>-ExecutionPolicy ByPass -File "[{AppVPackageRoot}]\..\Scripts\alkane.ps1" -message "DeploymentConfig UserScripts PublishPackage"</Arguments>
        <Wait RollbackOnError="true" Timeout="30"/>
      </PublishPackage>

      <UnpublishPackage>
        <Path>powershell.exe</Path>
        <Arguments>-ExecutionPolicy ByPass -File "[{AppVPackageRoot}]\..\Scripts\alkane.ps1" -message "DeploymentConfig UserScripts UnpublishPackage"</Arguments>
        <Wait RollbackOnError="false" Timeout="30"/>
      </UnpublishPackage>
      
    </UserScripts>

and also the following MachineScripts:

 <MachineScripts>
      <PublishPackage>
        <Path>powershell.exe</Path>
        <Arguments>-ExecutionPolicy ByPass -File "[{AppVPackageRoot}]\..\Scripts\alkane.ps1" -message "DeploymentConfig MachineScripts PublishPackage"</Arguments>
        <Wait RollbackOnError="true" Timeout="30"/>
      </PublishPackage>

      <UnpublishPackage>
        <Path>powershell.exe</Path>
        <Arguments>-ExecutionPolicy ByPass -File "[{AppVPackageRoot}]\..\Scripts\alkane.ps1" -message "DeploymentConfig MachineScripts UnpublishPackage"</Arguments>
        <Wait RollbackOnError="false" Timeout="30"/>
      </UnpublishPackage>
      
      <AddPackage>
        <Path>powershell.exe</Path>
        <Arguments>-ExecutionPolicy ByPass -File "[{AppVPackageRoot}]\..\Scripts\alkane.ps1" -message "DeploymentConfig MachineScripts AddPackage"</Arguments>
        <Wait RollbackOnError="true" Timeout="30"/>
      </AddPackage>
      <RemovePackage>
        <Path>powershell.exe</Path>
        <Arguments>-ExecutionPolicy ByPass -File "[{AppVPackageRoot}]\..\Scripts\alkane.ps1" -message "DeploymentConfig MachineScripts RemovePackage"</Arguments>
        <Wait RollbackOnError="false" Timeout="60"/>
      </RemovePackage>
  </MachineScripts>

and in the UserConfig.xml file I added the following UserScripts:

 <UserScripts>

      <StartProcess RunInVirtualEnvironment="true">
        <Path>powershell.exe</Path>
        <Arguments>-ExecutionPolicy ByPass -File "[{AppVPackageRoot}]\..\Scripts\alkane.ps1" -message "UserConfig UserScripts StartProcess"</Arguments>
        <Wait RollbackOnError="true"/>
        <ApplicationId>[{ProgramFilesX86}]\DependencyWalker\depends.exe</ApplicationId>
      </StartProcess>

      <ExitProcess>
        <Path>powershell.exe</Path>
        <Arguments>-ExecutionPolicy ByPass -File "[{AppVPackageRoot}]\..\Scripts\alkane.ps1" -message "UserConfig UserScripts ExitProcess"</Arguments>
        <Wait RollbackOnError="false"/>
        <ApplicationId>[{ProgramFilesX86}]\DependencyWalker\depends.exe</ApplicationId>
      </ExitProcess>
      
      <StartVirtualEnvironment  RunInVirtualEnvironment="true">
        <Path>powershell.exe</Path>
        <Arguments>-ExecutionPolicy ByPass -File "[{AppVPackageRoot}]\..\Scripts\alkane.ps1" -message "UserConfig UserScripts StartVirtualEnvironment"</Arguments>
        <Wait RollbackOnError="true"/>
      </StartVirtualEnvironment>
    
      <TerminateVirtualEnvironment>
        <Path>powershell.exe</Path>
        <Arguments>-ExecutionPolicy ByPass -File "[{AppVPackageRoot}]\..\Scripts\alkane.ps1" -message "UserConfig UserScripts TerminateVirtualEnvironment"</Arguments>
      <Wait RollbackOnError="false"/>
      </TerminateVirtualEnvironment>

      <PublishPackage>
        <Path>powershell.exe</Path>
        <Arguments>-ExecutionPolicy ByPass -File "[{AppVPackageRoot}]\..\Scripts\alkane.ps1" -message "UserConfig UserScripts PublishPackage"</Arguments>
        <Wait RollbackOnError="true" Timeout="30"/>
      </PublishPackage>

      <UnpublishPackage>
        <Path>powershell.exe</Path>
        <Arguments>-ExecutionPolicy ByPass -File "[{AppVPackageRoot}]\..\Scripts\alkane.ps1" -message "UserConfig UserScripts UnpublishPackage"</Arguments>
        <Wait RollbackOnError="false" Timeout="30"/>
      </UnpublishPackage>

    </UserScripts>

App-V 5 Scripting Context Test

In chronological order I added the package, published the package, launched the application, closed the application, unpublished the package and removed the package. I specified various user/deployment XML permutations during add and publish time. The results can be seen below.

App-V 5 Scripting Context Test Results

Test 1

  • Apply DeploymentConfig.xml
  • Do NOT apply UserConfig.xml
  • Publish to user
Script FileScript SectionScript TypeRun As AdminContext
DeploymentConfigMachineScriptsAddPackageTrueSystem
DeploymentConfigUserScriptsPublishPackageFalseUser
DeploymentConfigUserScriptsStartVirtualEnvironmentFalseUser
DeploymentConfigUserScriptsStartProcessFalseUser
DeploymentConfigUserScriptsExitProcessFalseUser
DeploymentConfigUserScriptsTerminateVirtualEnvironmentFalseUser
DeploymentConfigUserScriptsUnpublishPackageFalseUser
DeploymentConfigMachineScriptsRemovePackageTrueSystem

Test 2

  • Apply DeploymentConfig.xml
  • Do NOT apply UserConfig.xml
  • Publish to machine
Script FileScript SectionScript TypeRun As AdminContext
DeploymentConfigMachineScriptsAddPackageTrueSystem
DeploymentConfigMachineScriptsPublishPackageTrueSystem
DeploymentConfigUserScriptsStartVirtualEnvironmentFalseUser
DeploymentConfigUserScriptsStartProcessFalseUser
DeploymentConfigUserScriptsExitProcessFalseUser
DeploymentConfigUserScriptsTerminateVirtualEnvironmentFalseUser
DeploymentConfigMachineScriptsUnpublishPackageTrueSystem
DeploymentConfigMachineScriptsRemovePackageTrueSystem

Test 3

  • Do NOT apply DeploymentConfig.xml
  • Apply UserConfig.xml
  • Publish to user
Script FileScript SectionScript TypeRun As AdminContext
UserConfigUserScriptsPublishPackageFalseUser
UserConfigUserScriptsStartVirtualEnvironmentFalseUser
UserConfigUserScriptsStartProcessFalseUser
UserConfigUserScriptsExitProcessFalseUser
UserConfigUserScriptsTerminateVirtualEnvironmentFalseUser
UserConfigUserScriptsUnpublishPackageFalseUser

Test 4

  • Do NOT specify DeploymentConfig.xml
  • Apply UserConfig.xml
  • Publish to machine

Error – cannot specify UserConfig.xml if published to machine (-global)

Test 5

  • Apply DeploymentConfig.xml
  • Apply UserConfig.xml
  • Publish to user
Script FileScript SectionScript TypeRun As AdminContext
UserConfigUserScriptsPublishPackageFalseUser
UserConfigUserScriptsStartVirtualEnvironmentFalseUser
UserConfigUserScriptsStartProcessFalseUser
UserConfigUserScriptsExitProcessFalseUser
UserConfigUserScriptsTerminateVirtualEnvironmentFalseUser
UserConfigUserScriptsUnpublishPackageFalseUser
DeploymentConfigMachineScriptsRemovePackageTrueSystem
UserConfigUserScriptsPublishPackageFalseUser
UserConfigUserScriptsStartVirtualEnvironmentFalseUser
UserConfigUserScriptsStartProcessFalseUser
UserConfigUserScriptsExitProcessFalseUser
UserConfigUserScriptsTerminateVirtualEnvironmentFalseUser
UserConfigUserScriptsUnpublishPackageFalseUser

Test 6

  • Apply DeploymentConfig.xml
  • Apply UserConfig.xml
  • Publish to machine

Error – cannot specify UserConfig.xml if published to machine

Summary

A summary of these results is depicted in the table below:

ScriptScript SectionScript TypeCan Run Inside Virtual Environment?Potentially RUNsContext
DeploymentConfigUserScriptsStartProcessYesMultipleUser
DeploymentConfigUserScriptsExitProcessNoMultipleUser
DeploymentConfigUserScriptsStartVirtualEnvironmentYesMultipleUser
DeploymentConfigUserScriptsTerminateVirtualEnvironmentNoMultipleUser
DeploymentConfigUserScriptsPublishPackageNoMultipleUser/System*
DeploymentConfigUserScriptsUnpublishPackageNoMultipleUser/System*
DeploymentConfigMachineScriptsPublishPackageNoMultipleSystem
DeploymentConfigMachineScriptsUnpublishPackageNoMultipleSystem
DeploymentConfigMachineScriptsAddPackageNoOnceSystem
DeploymentConfigMachineScriptsRemovePackageNoOnceSystem
UserConfigUserScriptsStartProcessYesMultipleUser
UserConfigUserScriptsExitProcessNoMultipleUser
UserConfigUserScriptsStartVirtualEnvironmentYesMultipleUser
UserConfigUserScriptsTerminateVirtualEnvironmentNoMultipleUser
UserConfigUserScriptsPublishPackageNoMultipleUser
UserConfigUserScriptsUnpublishPackageNoMultipleUser

Additional Notes

  • Administrative privileges are required to publish globally, as such globally published packages will be published using the system account
  • You can not specify a UserConfig.xml file when publishing globally
  • The UserScripts in UserConfig.xml will override the UserScripts in DeploymentConfig.xml if both config files are applied
  • Only the StartProcess and StartVirtualEnvironment script types have the option to run inside the virtual environment.
  • Only AddPackage and RemovePackage run once in a package lifecycle.  Every other script type has the potential to run multiple times, including (Un)PublishPackage (for example, if we use Set-AppVClientPackage to specify a new config file we will need to re-publish the package, but not re-add it.)
  • * All UserScripts specified in the DeploymentConfig file will run in a user context when published to a user.  However, when published to a machine all UserScripts specified in the DeploymentConfig file will run in a user context with the exception of PublishPackage and UnpublishPackage, which will run under a system context.

 

Using Junctions with App-V 5 to resolve Path Length Limitations

This blog explains the process of using junctions with App-V 5 to resolve application issues caused by hard-coded paths or path length limitations.

Using Junctions with App-V 5

I was migrating an old application recently which launched Macromedia Director.  It launched fine during sequencing and when installed locally, but when I ran the App-V 5 version i saw the error message:

Unable to load movie playlist. Does the .INI file exist? It must contain a section ‘[Movies]’ with an entry ‘Movie01=Pathname.dir’.

To cut a story short, it turns out the when App-V mounts the application to a path such as C:\ProgramData\App-V\[PackageGUID]\[VersionGUID]\Root\Vocal Pathology_1\Vocal Pathology_I.exe  the path name becomes too long and exceeds the character limit of 127 characters!  See here.

The only way I could think of resolving this via an App-V package would be to spoof it by using a symbolic link, or more specifically a ‘junction’.

Both symbolic links and junctions essentially link one location to another location.  But creating a junction seemed like a better option according to this post on SuperUser – I particularly liked the way that remote users could access the junction from a remote computer.

Our task then, was to create a short path junction that would link to the longer path used by App-V.

When creating a junction on the command line, the syntax is:

mklink /J "[Non Existing Junction Full Path]" "[Existing App-V Full Path]"

It’s important to note that where you are linking from should NOT exist when you run this command, and where you are linking to SHOULD exist. 

We will create a junction with the path of C:\VocalPathology after the package is added (hence at publish time) so that the target path exists, and for cleanliness we will also remove the junction at the time of un-publishing the app.

In the UserConfig.xml file (my apps are published to users and not machines) I added the following code – note that I first rename C:\VocalPathology if it already exists (otherwise creating a junction will fail), then create the junction.  Also note that the token [{AppVPackageRoot}] ultimately resolves to C:\ProgramData\App-V\[PackageGUID]\[VersionGUID]\Root and that our target executable lives in a sub folder of the package root called ‘Vocal Pathology_1’.
.

<UserScripts>
	<PublishPackage>
		<Path>cmd.exe</Path>
		<Arguments>/c move /Y "C:\VocalPathology" "C:\VocalPathology.old" &amp; mklink /J "C:\VocalPathology\" "[{AppVPackageRoot}]\Vocal Pathology_1\"</Arguments>
		<Wait RollbackOnError="false" Timeout="30"/>  		
	</PublishPackage> 		
	<UnpublishPackage>
		<Path>cmd.exe</Path>
		<Arguments>/c rmdir "C:\VocalPathology\"</Arguments> 
		<Wait RollbackOnError="false" Timeout="30"/>  		
	</UnpublishPackage>
</UserScripts>

Now that we have created the junction, we need to amend where the shortcut points to in our UserConfig file! Find the <Shortcuts> section and amend the <Target> element.  Beforehand it points to the the full App-V location since it uses [{AppVPackageRoot}]:

<Target>[{AppVPackageRoot}]\Vocal Pathology_1\Vocal Pathology_I.exe</Target>

But we will need to hard-code the shortcut to point to our junction (shorter path) instead. The new shortcut will be:

<Target>C:\VocalPathology\Vocal Pathology_I.exe</Target>

When the package gets published, the shortcut will get ‘sucked’ into the virtual file system since App-V will append the /appvve parameter like so:

"C:\VocalPathology\Vocal Pathology_I.exe" /appvve:[PackageGUID]_[VersionGUID]

 

Restricting Operating Systems in App-V 5

When restricting operating systems in App-V 5, App-V 5 sees Windows 10 x64 and Windows Server 2016 as the same operating system.  Here we present an alternative method that uses App-V 5 scripting to filter your operating system restrictions for App-V 5.

Restricting Operating Systems in App-V 5

I’ve recently been working on an App-V 5 application where two variants are required – one is for Windows 10 x64 and one is for Windows Server 2016 (Citrix virtual desktop sessions).

To reduce administration, my current client wanted both App-V variants to be published to the same Active Directory group so that users could float between their standard desktops and Citrix sessions seamlessly.  My first thoughts were that I could simply set the ‘target OS’ condition for each variant….until I stumbled upon this:

Windows 10 Server 16 Target OS

In App-V 5 we can’t distinguish between Windows 10 x64 and Windows Server 2016 by using the ‘Target OS’ functionality.  So instead I wrote the following AddPackage script to add similar logic in the Deployment.config file:

<MachineScripts>
	<AddPackage>
		<Path>powershell.exe</Path>
		<Arguments>-ExecutionPolicy ByPass -WindowStyle Hidden -Command  "&amp; { if ([int](Get-CimInstance Win32_OperatingSystem | Select-Object -Expand ProductType) -eq 3) { Exit 0 } else { Exit 1 } }"</Arguments>
		<Wait RollbackOnError="true" Timeout="30"/>
	</AddPackage>
</MachineScripts>

This line of PowerShell merely checks to see if the current machine is a server platform (a ProductType of 3) or not (Work Station = 1, Domain Controller = 2, Server = 3).  If it is a server platform the script will return 0 and AddPackage will run successfully .  If it is not a server package the script will fail (return a non-zero value of 1) and roll back.  This will of course be silent to the end-user.

It doesn’t go into the granularity of checking the specific operating system, but it serves our purpose well since we are only packaging for Windows 10 and Server 2016.

 

Synchronise App-V 5 Client with Publishing Server

I’ve recently set up a couple of virtual machines to start sequencing and testing App-V 5 packages.  When setting up the client, rather than configure it for local testing I wanted to synchronise it with the App-V 5 publishing service.  We can do this (at the most basic level) using a PowerShell one-liner:

Add-AppvPublishingServer -name "<NAMEOFSERVER>" -Url "<URL OF Publishing Service>" | Sync-AppvPublishingServer

When you run this from an elevated PowerShell prompt, you may see one or more of the following errors:

Sync-AppvPublishingServer – Application Virtualization Service failed to complete requested operation.

HttpRequest sendRequest failed.

Getting server publishing data failed.

This is probably because the URL you specified is incorrect.  To find the correct URL, navigate the registry on your App-V 5 publishing server to the following location:

HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\AppV\Server\PublishingService

Grab the value of PUBLISHING_MGT_SERVER.  Replace ‘localhost’ with the fully qualified domain name (FQDN) of the server itself.  And replace the port with the value in PUBLISHING_WEBSITE_PORT.  Assuming that:

PUBLISHING_MGT_SERVER=http://localhost:8000/

PUBLISHING_WEBSITE_PORT=8001

and the FQDN of the server is appv5.alkane.uk

Your PowerShell one-liner would be:

Add-AppvPublishingServer -name "ALKANEAPPV5" -Url "http://appv5.alkane.uk:8001/" | Sync-AppvPublishingServer

To verify this, if you put the URL into a browser session you should see an XML view of published packages for the authenticated user.

App-V 5 and Killing Child Processes

I recently packaged an application that, upon launch, started a process called EntDistributorService.exe (the parent process).

EntDistributorService.exe spawned a child process called hvced.exe (I knew hvced.exe was a child process by running the wmic command found here.), which remained running when the launched application was closed.

When a process contained within the App-V package remains open, it presents issues when we need to unpublish it – the App-V package can’t be unpublished if it is still deemed as being in a ‘running’ state!

I tried to kill hvced.exe using a script (I tried taskkill.exe and Powershell’s Stop-Process) which ran in the ExitProcess scripting phase of the UserConfig.xml.  Unfortunately the context that these scripts run in didn’t have enough permissions to kill the process, since EntDistributorService.exe was launched in a SYSTEM context and as a result hvced.exe was also running in a SYSTEM context.

After all this fiddling around, it turned out that the solution was quite simple really.  In the DeploymentConfig.xml file we can simply add the parent file who’s child processes we want to terminate like so:

<TerminateChildProcesses>      
    <Application Path="[{AppVPackageRoot}]\VFS\ProgramFilesCommonX86\HealthVISION\EntDistributorService.exe" />         
</TerminateChildProcesses>

And it worked like a charm.

Rename App-V 4.6 Package Before Converting to App-V 5.1

I’ve been converting a bunch of apps recently from App-V 4.6 format to App-V 5.1 format.  I knew this could be done using PowerShell cmdlets, so rather than reinvent the wheel I used a great script written by Tim Mangan to do this for me.

The problem I had was that our App-V 4.6 packages all had a specific naming convention that included the characters ‘AV46’…..or worse….didn’t have a consistent naming convention whatsoever!  So it meant that when these packages were converted to App-V 5.1 they either still contained the characters ‘AV46’ (which looked stupid in my App-V 5.1 console) or were named inconsistently.

Since the ConvertFrom-AppvLegacyPackage cmdlet doesn’t expose a way of naming the ‘output’ package, and I didn’t want to open and re-save each of my converted App-V 5.1 packages to merely change the package name, the only option I saw was to rename the App-V 4.6 package before I converted it!  So that’s exactly what this script does!

Why is it written in VBScript and not PowerShell?  Simply so we can drag a package folder onto the .vbs file and rename it.  Otherwise to do the same for PowerShell would require hacking around in the registry.

How it works

Consider you have a folder containing the following App-V 4.6 package:

App-V 4.6 package

First you should grab the script below, and save it as (for example) RenameAV46.vbs.

You would then simply drag the parent package folder (Gateway_WinDip_2.2_32_AV46_R01) on to RenameAV46.vbs.  It will then prompt you for a new package name, where you would specify the new package name (for example) ‘Gateway_WinDip_2.2_32_AV51_R01’ or similar.

The script renames the minimal amount required to prepare your App-V 4.6 package for App-V 5.1 conversion.  For example, it will not rename the icons folder and re-point the OSD shortcut icons since this is not required for the conversion process to be successful.

Once you have renamed the package, it is then ready to run through Tim’s conversion script!

Option Explicit

Dim objFSO : Set objFSO = createobject("Scripting.FilesystemObject")

Dim objXMLDoc : Set objXMLDoc = CreateObject("Microsoft.XMLDOM") 
objXMLDoc.async = False 

Dim objXMLDoc2 : Set objXMLDoc2 = CreateObject("Microsoft.XMLDOM") 
objXMLDoc2.async = False 
	
Dim folderPath : folderPath = wscript.arguments(0)
Dim oldPackageName : oldPackageName = ""
Dim currentFilePath : currentFilePath = ""
Dim currentFileName : currentFileName = ""
Dim currentParentFilePath : currentParentFilePath = ""
Dim currentFileExtension: currentFileExtension = ""

Dim newFilePath : newFilePath = ""
Dim newFileName : newFileName = ""

Dim existingPATH : existingPATH = ""
Dim newPATH : newPATH = ""
Dim existingHREF : existingHREF = ""
Dim newHREF : newHREF = ""
Dim existingNAME : existingNAME = ""
Dim newNAME : newNAME = ""

Dim newPackage : newPackage = ""

Dim parentFolderPath : parentFolderPath = objFSO.GetParentFolderName(folderPath)
Const ForReading = 1
Const ForWriting = 2		

Public Function DecodeXml(TextToDecode)
    'Take text that has been encoded for XML and change it to normal text
    Dim Res
    Res = Replace(Replace(Replace(Replace(TextToDecode, "&quot;", """"), "&gt;", ">"), "&lt;", "<"), "&amp;", "&")
    DecodeXml = Res
End Function

Public Function EncodeXml(TextToEncode)
    'Take text and change special chars so that it can be included in an XML file
    Dim Res
    Res = Replace(Replace(Replace(Replace(TextToEncode, "&", "&amp;"), ">", "&gt;"), "<", "&lt;"), """", "&quot;")
    EncodeXml = Res
End Function

Function GetNewFilePath(existingFilePath, oldPackageName, newPackageName)	
	'get new file name based on old file name, stripping out any version ("_!","_£" etc)	
	Dim RegX : Set RegX = new RegExp
				
	RegX.Pattern = "(" & oldPackageName & ")(_\d+)*"
	RegX.Global = True

	GetNewFilePath = RegX.Replace(existingFilePath, newPackageName)	
End Function

Sub RenameFile(currentFilePath, newFilePath)
	If (currentFilePath <> newFilePath) Then
		objFSO.MoveFile currentFilePath, newFilePath
		currentFilePath = newFilePath		
	End if			
End Sub

Dim newPackageName : newPackageName = InputBox("Enter new package name")

If (trim(newPackageName) = "") Then
	MsgBox "The package name was blank - please populate.  Quitting."
	wscript.quit
End If

Dim newPackagePath : newPackagePath = parentFolderPath & "\" & newPackageName

'rename package folder with new name
objFSO.MoveFolder folderPath, newPackagePath

Dim newPackagePathFolder : Set newPackagePathFolder = objFSO.GetFolder(newPackagePath)
Dim nodetext,folder,file

'get SPRJ (project) name
For Each file In newPackagePathFolder.Files
	Select Case UCase(objFSO.GetExtensionName(file.Path))
		Case "SPRJ"
			oldPackageName = objFSO.GetBaseName(file.Path)
			Exit For
	End Select
Next

'iterate though package files
For Each file In newPackagePathFolder.Files
	
	currentFilePath = file.Path
	
	'We need to be careful of the encoding in the DEFAULTOSD element
	Select Case UCase(objFSO.GetExtensionName(currentFilePath))
	Case "MSI","XML"				
		'just rename these file types
		newFilePath = GetNewFilePath(currentFilePath, oldPackageName, newPackageName)	
		RenameFile currentFilePath, newFilePath	
	Case "SPRJ"		
		
		objXMLDoc.load(currentFilePath)
		
		If 0 <> objXMLDoc.ParseError Then
			msgbox "SPRJ " & objXMLDoc.ParseError.Reason & " " & currentFilePath
		Else		
			'change PACKAGEFILE
			Dim PackageFile : Set PackageFile = objXMLDoc.selectSingleNode(".//SEQUENCERPROJECT/PACKAGEFILE")
			If Not PackageFile Is Nothing Then	  
				existingPATH = PackageFile.getAttribute("Path")		

				'we rename the SFT in here instead of in the SELECT...CASE statement in case there are multiple SFTs (messy package folder)
				newFilePath = GetNewFilePath(newPackagePathFolder & "\" & existingPATH, oldPackageName, newPackageName)
				RenameFile newPackagePathFolder & "\" & existingPATH, newFilePath				
				
				newPATH = GetNewFilePath(existingPATH, oldPackageName, newPackageName)	
				PackageFile.setAttribute "Path",newPATH
				objXMLDoc.Save(currentFilePath)	
			Else
				Msgbox "cannot find PACKAGEFILE node"
			end if
			
			Set PackageFile = Nothing				
			
			'the DEFAULTOSD node is actually encoded XML, so here we read it all in so we can parse the XML DOM and update CODEBASE HREF and PACKAGE NAME
			
			Dim DefaultOSDNode : Set DefaultOSDNode = objXMLDoc.selectSingleNode(".//SEQUENCERPROJECT/DEFAULTOSD")
			If Not DefaultOSDNode Is Nothing Then
			
				'remember that the DEFAULTOSD node contains XML itself, so we re-load the contents into the XML parser
				nodetext = DefaultOSDNode.text

				objXMLDoc2.loadXML(nodetext)
				
				If 0 <> objXMLDoc2.ParseError Then
					msgbox "DEFAULTOSD CODEBASE " & objXMLDoc2.ParseError.Reason
				Else
				
					Dim CodebaseNode : Set CodebaseNode = objXMLDoc2.selectSingleNode(".//SOFTPKG/IMPLEMENTATION/CODEBASE")
					If Not CodebaseNode Is Nothing Then	  
						existingHREF = CodebaseNode.getAttribute("HREF")
						newHREF = GetNewFilePath(existingHREF, oldPackageName, newPackageName)				
						CodebaseNode.setAttribute "HREF",newHREF							
					Else
						Msgbox "cannot find CODEBASE node"
					end if
					
					Set CodebaseNode = Nothing	
					
					Dim PackageNode : Set PackageNode = objXMLDoc2.selectSingleNode(".//SOFTPKG/PACKAGE")
					If Not PackageNode Is Nothing Then
						PackageNode.setAttribute "NAME",newPackageName							
					Else
						Msgbox "cannot find PACKAGE node"
					end if
					
					Set PackageNode = Nothing			
									
				End If			
				'set new value for DEFAULTOSD contents
				DefaultOSDNode.text = objXMLDoc2.selectSingleNode(".//SOFTPKG/").xml
				objXMLDoc.Save(currentFilePath)	
			Else
				Msgbox "cannot find DEFAULTOSD node"
			End If					
			Set DefaultOSDNode = Nothing	
		End If	
		
		newFilePath = GetNewFilePath(currentFilePath, oldPackageName, newPackageName)	
		RenameFile currentFilePath, newFilePath
		
	Case "OSD"
		
		objXMLDoc.load(currentFilePath)
		
		If 0 <> objXMLDoc.ParseError Then
			msgbox "OSD " & objXMLDoc.ParseError.Reason & " " & currentFilePath
		Else		
			Set CodebaseNode = objXMLDoc.selectSingleNode(".//SOFTPKG/IMPLEMENTATION/CODEBASE")
			If Not CodebaseNode Is Nothing Then	   
				existingHREF = CodebaseNode.getAttribute("HREF")
				newHREF = GetNewFilePath(existingHREF, oldPackageName, newPackageName)				
				CodebaseNode.setAttribute "HREF",newHREF
				objXMLDoc.Save(currentFilePath)	
			Else
				Msgbox "cannot find CODEBASE node"
			end if
			
			Set CodebaseNode = Nothing				
		End If			
	End Select	
Next

MsgBox "Done"

Set objXMLDoc = Nothing
Set objFSO = Nothing

 

 

Start an App-V 5 Process immediately after Publishing

I have a piece of software that acts as an ‘agent’ sitting in the background (in the System Tray) waiting to intercept print requests.  It needs to be running all the time since it intercepts print requests from ANY desktop application.

The target platforms are a combination of standard desktops and non-persistent VDI desktops.  Making it work with standard desktops wasn’t really the problem – by default the software placed a shortcut in the Startup folder and after publishing, logging off and logging in the background process started.

The trouble with a non-persistent VDI is that when a user logs off and logs back in, they get a completely fresh desktop experience and all the apps get published again.  As a result of this we can’t just lump some logic in a Run key or the Startup folder since the application is published after these features are processed.

Combined with this there were another 2 applications that needed connection-grouping with this agent application.

Unless there’s a more elegant way of achieving this that I’m not aware of, I present my solution forthwith:

Firstly, since we’re publishing to users, I have added a user script at publish time in the AppxManifest.xml file (or you could use the UserConfig.xml file – your choice depending on which version of App-V you’re using):

		
<appv:UserScripts>
        <appv:PublishPackage>
            <appv:Path>cmd.exe</appv:Path>
            <appv:Arguments>/c START "" "powershell.exe" -ExecutionPolicy ByPass -WindowStyle Hidden -File "[{AppVPackageRoot}]\..\Scripts\runProcess.ps1"</appv:Arguments>
            <appv:Wait RollbackOnError="false" />
        </appv:PublishPackage>
    </appv:UserScripts>
	

What’s important to note is that we can’t just point at our background process for 2 reasons:

  1. Because the application is not yet published at this point, and so we can’t access/run it
  2. Even if we could access/run it, the publishing process would stick at running the .exe since the .exe will always be running and the PublishPackage action will never release the handle (unless we manually kill it).  And hence the package would never publish!

Instead we’ve called cmd.exe with the START command.  What this does is launch powershell.exe as a separate process and does NOT wait for it to complete.  Hence the publishing of the app will complete as normal.  Meanwhile in the background, powershell.exe runs a script that I’ve placed in the Scripts folder in the background.  So what does the script do?  In essence it:

  • Loops 10 times maximum, with a 5 second delay between each loop, and attempts to get the package (Get-AppvClientPackage) and ensure it is published to the user (IsPublishedToUser).  We cannot run the process if the package is not published.
  • Loops 10 times maximum, with a 5 second delay between each loop, and attempts to get the connection group (Get-AppvClientConnectionGroup) and ensure it is enabled (IsEnabledToUser).  If using a connection group, we cannot enable a connection group if a package is in use!  So we need to wait until the connection group is enabled before we attempt to start the process.
  • Loops 10 times maximum, with a 5 second delay between each loop, and attempts to start the process (Start-Process).  There’s no reason why it shouldn’t start first time, but I still add in some retries just in case.

Here is the script (runProcess.ps1)

#variable to store the current script folder (in case, for ease, we want to put our exe's in the same folder as the script)
$ScriptFolder = $PSScriptRoot

#path that points to the 'Root' folder inside an app-v package
$appvRoot = split-path $ScriptFolder -parent
$appvRoot = $appvRoot + "\Root"

#default number of attempts
$attempts = 10
#path to exe inside app-v package
$processPath = "$appvRoot\VFS\ProgramFilesX86\Equitrac\Express\Client\EQMsgClient.exe"
#process name without exe (used with Get-Process)
$processName = "EQMsgClient"
#name of App-V package
$packageName = "Nuance_EquitracExpressClient_5.4.23.4801"
#name of connection group
$connectionGroupName = "Equitrac Express"

#initialise
$packageObject = $false
$connectionGroupObject = $false
$newProcess = $null

$eventMessage = "Attempting to run: " + $processPath
write-host $eventMessage


#wait until package is published
$packageObject = Get-AppvClientPackage -Name $packageName -ErrorAction SilentlyContinue

while (($packageObject -eq $null -Or (!($packageObject.IsPublishedToUser))) -And $attempts -gt 0)
{   
    Start-Sleep -s 5
    $attempts = $attempts - 1

    $eventMessage = "Checking if the following package is published: " + $packageName + " (Attempts remaining: " + $attempts + ")"
    write-host $eventMessage  

    $packageObject = Get-AppvClientPackage -Name $packageName -ErrorAction SilentlyContinue  
}


if ($packageObject.IsPublishedToUser)
{
    $eventMessage = "The following package is published: " + $packageName
    write-host $eventMessage

    #now we need to check that the connection group has applied and is published!
    $connectionGroupObject = Get-AppvClientConnectionGroup -Name $connectionGroupName -ErrorAction SilentlyContinue
    
    #reset attempts
    $attempts = 10

    while (($connectionGroupObject -eq $null -Or (!($connectionGroupObject.IsEnabledToUser))) -And $attempts -gt 0)
    {   
        Start-Sleep -s 5
        $attempts = $attempts - 1

        $eventMessage = "Checking if the following connection group is enabled: " + $connectionGroupName + " (Attempts remaining: " + $attempts + ")"
        write-host $eventMessage

        $connectionGroupObject = Get-AppvClientConnectionGroup -Name $connectionGroupName -ErrorAction SilentlyContinue
    }

    
    if ($connectionGroupObject.IsEnabledToUser)
    {
        $eventMessage = "The following connection group is enabled: " + $connectionGroupName
        write-host $eventMessage

        #reset attempts
        $attempts = 10

        #now try and start our process
        while (((Get-Process -Name $processName -ErrorAction SilentlyContinue) -eq $null) -And $attempts -gt 0)
        {
            if (Test-Path $processPath)
            {
                $newProcess = Start-Process -FilePath $processPath -NoNewWindow -ErrorAction SilentlyContinue       
            }
            else
            {
                $eventMessage = "Process path : " + $processPath + " was not found.  Unable to start process."
                write-host $eventMessage
                break
            }
            $attempts = $attempts - 1

            $eventMessage = "Trying to start process: " + $processPath + " (Attempts remaining: " + $attempts + ")"
            write-host $eventMessage

            Start-Sleep -s 5
        }
        
        if ($newProcess -eq $null)
        {
            $eventMessage = "Process : " + $processPath + " could not be launched."
            write-host $eventMessage
        }
        else
        {
            $eventMessage = "Process : " + $processPath + " was launched successfully."
            write-host $eventMessage
        }
        
    }
    else
    {
        $eventMessage = "Connection Group : " + $connectionGroupName + " was not enabled.  Unable to start process."
        write-host $eventMessage
    }
}
else
{
    $eventMessage = "Package : " + $packageName + " was not published.  Unable to start process."
    write-host $eventMessage
}

Things to tweak

The variables at the top (package names etc).  Also remember this targets packages published to the user, not the machine.  So you’ll need to tweak things like ‘isEnabledToUser’ and ‘isPublishedToUser’ etc.  Also you may not be using a connection group like me, so you could chop that logic out too.

A note before you commence…

Since the background process is always running, it obviously can’t be unpublished instantly (because the package will be in use).  Luckily we’re running the App-V 5 SP2 client which will set up a pending task to un-publish it when the user logs off and back in.  You may want to consider this before implementing it.

Using Taskkill.exe to Kill Multiple Processes on One Line

I’ve had an issue with my current App-V 5 application.  When I launch an executable called CentralSurveillance.exe, it spawns another two processes called K2DataLogger.exe and K2MSService.exe.  And these remain open even when I close CentralSurveillance.exe.  My first thoughts were that these were child processes, so I added the following to the DeploymentConfig:

<TerminateChildProcesses>
<Application Path="[{ProgramFilesX86}]\K2 Medical Systems\Guardian\CentralSurveillance.exe" />
</TerminateChildProcesses>

Sure enough, when I closed the instance of CentralSurveillance.exe….the instances of K2DataLogger.exe and K2MSService.exe were still running!  So I dug a little deeper – were these actually child processes or not?

I opened up a command prompt and ran:

wmic process Get Name,Description,CommandLine,ProcessId,ParentProcessId /format:list > processes.txt

What this does is list the name, description, command line, process id and parent process id of each running process and outputs it to a text file called processes.txt.  The results (after I’d deleted the irrelevant stuff) were as follows:

CommandLine="C:\Program Files\Microsoft Application Virtualization\Client\AppVClient.exe"
Description=AppVClient.exe
Name=AppVClient.exe
ParentProcessId=496
ProcessId=2664

CommandLine=C:\windows\system32\svchost.exe -k DcomLaunch
Description=svchost.exe
Name=svchost.exe
ParentProcessId=496
ProcessId=612

CommandLine=C:\windows\Explorer.EXE
Description=explorer.exe
Name=explorer.exe
ParentProcessId=4032
ProcessId=4224

CommandLine="C:\Users\xxxxx\AppData\Local\Microsoft\AppV\Client\Integration\07390066-7746-41A4-B6FB-3D1977D289BA\Root\VFS\ProgramFilesX86\K2 Medical Systems\Guardian\CentralSurveillance.exe"
Description=CentralSurveillance.exe
Name=CentralSurveillance.exe
ParentProcessId=4224
ProcessId=6120

CommandLine="C:\ProgramData\App-V\07390066-7746-41A4-B6FB-3D1977D289BA\CF8C5523-5038-4C73-991E-EBB4812B110B\Root\VFS\ProgramFilesX86\K2 Medical Systems\Guardian\K2DataLogger.exe"
Description=K2DataLogger.exe
Name=K2DataLogger.exe
ParentProcessId=2664
ProcessId=6040

CommandLine="C:\ProgramData\App-V\07390066-7746-41A4-B6FB-3D1977D289BA\CF8C5523-5038-4C73-991E-EBB4812B110B\Root\VFS\ProgramFilesX86\K2 Medical Systems\Guardian\K2MSService.exe"
Description=K2MSService.exe
Name=K2MSService.exe
ParentProcessId=2664
ProcessId=3872

We can clearly see that the parent process of K2DataLogger.exe and K2MSService.exe is NOT CentralSurveillance.exe (process id 6120) but instead AppVClient.exe (process id 2664).  And this is probably why TerminateChildProcesses will not work in this instance.  The workaround I used is to run an ExitProcess script so that when we close CentralSurveillance.exe, it kills both K2DataLogger.exe and K2MSService.exe.

I did this like so:

<UserScripts>
<ExitProcess>
<Path>CMD.EXE</Path>
<Arguments>/C taskkill /im "K2DataLogger.exe" /f /t &amp; taskkill /im "K2MSService.exe" /f /t</Arguments>
<Wait RollbackOnError="false" />
<ApplicationId>[{ProgramFilesX86}]\K2 Medical Systems\Guardian\CentralSurveillance.exe</ApplicationId>
</ExitProcess>
</UserScripts>

You can see that I can specify multiple command lines to run via CMD.exe by separating each command by an ampersand (& – this needs to be URL encoded in an XML file which is why it would appear as &amp; instead).  In the workaround above I specify two processes.  However by using brackets and nesting commands, I can specify more!  Here’s an example of an argument that kills 4 processes:

taskkill /im "odbcad32.exe" /f /t & (taskkill /im "wordpad.exe" /f /t & (taskkill /im "notepad.exe" /f /t & taskkill /im "mspaint.exe" /f /t))