Symantec Endpoint Protection 12.X on OSX

If you are using ConfigMgr 2012 (or one of the plugins hi-jacking the infrastructure – such as Parallels) to manage the Mac OSX devices there are some caveats to the ordinary guide of both from Symantec on howto install the Symantec Endpoint Protection aswell as the Microsoft guide “How to Create and Deploy Applications for Mac Computers in Configuration Manager”.

First of all you need access to the Symantec Endpoint Protection media and to actually start the installation. Once its started you can immediately headover to Symantecs guide on howto Deploy (keyword deploy) SEP with Apple Remote Desktop or Casper.

The guide states that once the installation is fired up (and you acknowledge that its OK if this requires you to restart the computer) you can access the Tools-menu

image

Clicking the “Create remote deployment package” will immediately fire off a new menu that will allow you to choose a file-name and a place where the new package can be saved.

image

Once the deployment package is created you you will receive a helpful note about only deploying this with a deployment system, and remembering to restart afterwards.

image

As per the ConfigMgr article on howto deploy applications for Mac there is a need to convert the generic PKG-format to the ConfigMgr compatible (and unique) CMMAC format.

This specific package does unfortunately not provide any detection mechanism, so the command-line to convert this package is.

./CMApputil –c SEPRemote.pkg –o /volumes/usbstick/ -s

-c points the utility to our original package

-o points to the where we want to place our final package (named SEPRemote.cmmac)

-s will omit the creation of the detection rules

ConfigMgr Client failure – CCMRegisterWinTask

If the SCCM 2012 client fails with  a generic 1603 error at the following phase;


MSI: Action 7:01:41: CcmSetServiceConfig. Sets WMI ServiceRootDir and configures CCMEXEC service.            ccmsetup            2015-01-26 07:01:41       1764 (0x06E4)

MSI: Action 7:01:41: CcmRegisterWinTaskRollback. Rolls back the changes made by CcmRegisterWinTask.        ccmsetup            2015-01-26 07:01:41       1764 (0x06E4)

MSI: Action 7:01:41: CcmRegisterWinTask. Registers tasks with Windows Task Scheduler.           ccmsetup          2015-01-26 07:01:41            1764 (0x06E4)

MSI: Action 7:01:42: Rollback. Rolling back action:         ccmsetup          2015-01-26 07:01:42       1764 (0x06E4)

File C:\WINDOWS\ccmsetup\{181D79D7-1115-4D96-8E9B-5833DF92FBB4}\client.msi installation failed. Error text: ExitCode: 1603

Action:
ErrorMessages: ccmsetup          2015-01-26 07:02:14       1764 (0x06E4) 

 

This is usually caused by failures within the Task Scheduler and those errors will also happen outside of ConfigMgr.

In order of preference, try the following suggested solutions;

1. Run the below command and restart the computer

CHKDSK C: /R 

2. Run the below command and restart the computer

fsutil resource setautoreset true c:\

3. Delete all files in c:\Windows\System32\config\TxR and restart the computer

App-V 5, ConfigMgr compliance and fixes

App-V 5 has recently gotten hit by two odd behaviors relating to an update and .NET Framework 4.5.2.

KB2984972 was released as an update for RDC, and caused some havoc both for App-V 4 and App-V 5. The workaround is documented in the article and essentially allows anyone to remove the unfortunate end-user experience by adding some registry keys.

.NET Framework 4.5.2 was released and quite early on people started noticing that a freeze could be experienced when using certain App-V applications. The culprit seems to be the processes wisptis.exe, and the issue could temporarily be worked around by terminating the process.

App-V 5 got hit by two issues that both were resolved by adding registry keys under the registry key ObjExclusions. The Gladiator has written an article that details more about this registry key, the purpose of it and the effects of it. The article is focused on App-V 4, however the knowledge and concepts still apply to App-V 5.

Under (HKLM\Software\Microsoft\AppV\Subsystem\ObjExclusions) this registry key there are a lot of registry keys starting at 1 and going upwards. Each registry key contains a value (oh, really?) that is the name of an object that is not virtualized.  Anyone can append new values by using the next available number. Aaron Parker wrote a great article on howto leverage Group Policy to add the requested registry keys to resolve the issues for KB2984972.

Let’s detail the fun fact about this registry key;

There are in a default installation of App-V registry keys from 1-92. On any given default installation the next available number we can use is 93. We now have two issues and would therefore end up with two extra registry keys (93 and 94). My guess is that Microsoft might potentially include these two above recommended registry keys in a future installation of App-V when a new version comes out. Forcing these values to be added to a specific number in the series could potentially throw other valuable exclusions out the window…

Therefore I personally voted against Group Policy (Preferences) and decided to go the route of ConfigMgr Compliance Settings.

By creating a configuration item I can achieve the following;

Detect if the specific value is already in the list
Find the next available number to create a new registry key in
Append the value if it doesn’t already exist.

In the end, this is what I came up with;

appv_ci

Detect if the App-V client is installed;

appv_detection

Two checks for each specific registry key;

appv_check

Create a rule set that will allow for remediation;

appv_wsptis

Scripts part of the Configuration Item. This sample is from the fix for KB2984972.

Check:

$regKey = "HKLM:\SOFTWARE\Microsoft\AppV\Subsystem\ObjExclusions"
$p = Get-ItemProperty $regKey
$kb2984972 = $p.PSObject.Properties | where { $_.Name -match "[0-9]" -and $_.Value -eq "TermSrvReadyEvent" } | select-object -ExpandProperty Name -ErrorAction SilentlyContinue

if(($? -and ($kb2984972 -ne $null))) {
1
}
else {
-1
}

Remediation:

$regKey = "HKLM:\SOFTWARE\Microsoft\AppV\Subsystem\ObjExclusions"
$p = Get-ItemProperty $regKey
$topvalue = $p.PSObject.Properties | Where-Object { $_.Name -match "[0-9]" } | Sort-Object -Property Name -Descending | Select-Object -first 1 -ExpandProperty Name
$topvalue = 1 + $topvalue

Function New-RegistryKey([string]$key,[string]$Name,[string]$type,[string]$value)

{

#Split the registry path into its single keys and save

#them in an array, use \ as delimiter:

$subkeys = $key.split("\")

#Do this for all elements in the array:

foreach ($subkey in $subkeys)

{

#Extend $currentkey with the current element of

#the array:

$currentkey += ($subkey + '\')

#Check if $currentkey already exists in the registry

if (!(Test-Path $currentkey))

{

#If no, create it and send Powershell output

#to null (don't show it)

New-Item -Type String $currentkey | Out-Null

}

}

#Set (or change if alreday exists) the value for $currentkey

Set-ItemProperty $CurrentKey $Name -value $Value -type $type

}

New-RegistryKey $regkey $topvalue "String" "TermSrvReadyEvent"

As a final treat. Here is the Configuration Item – ready to be imported into ConfigMgr.

 

2014-12-07 – Sebastian Gern stated an additional registry key for WISPTIS. The Configuration Item is also updated with the new settings.

Windows Installer Source Management

As part of ensuring that installations work “OK” if initiating a repair regardless of how many servers are terminated or ConfigMgr caches are deleted – here comes a summary of the feature.

Whats the purpose of this feature?

To ensure that the source-files for any MSI-based installation is available, even if they are removed from ConfigMgr cache or a single / multiple DPs are terminated.

How do you use this feature?

For each deployment type, you can configure the ProductCode that ConfigMgr will track;

clip_image001

Select the MSI-file part of the source-files by clicking Browse (the one in the red square). The file you select should (as you might guess…) be part of the deployment types content.

I realize that some installers are less than ideal for this feature. Any standard MSI will work out of the box, however if the MSI is embedded within an executable, or if the installation is multiple MSIs – this feature may not be a perfect fit. You can of course test it, or bring it up for discussion with everyone.

What happens at the client, when I do this?

Any client which has the software made available / forced to it will have the source updated on the fly. You can trigger the action to update the source-location manually by using this action;

clip_image002

The source-location to the closest DP will be appended to the Source-list for any managed application. Do note that nothing is changed in Net and Media. Only URL is appended. Original source-location will be intact – regardless if files are still there or not.

image

How do I troubleshoot this?

All actions are logged here;

SrcUpdateMgr.log

Sample output;

Adding install source C:\WINDOWS\ccmcache\5d\ to source list for product {1AD1DCA6-73FE-4803-858C-3441DF875BC1}            SrcUpdateMgr   2014-05-01 23:01:38       5944 (0x1738)

UpdateURLWithTransportSettings(): OLD URL – http://server/sms_dp_smspkg$/content_b0bd89d3-1cfb-4348-97bf-590f75672d0c.1            SrcUpdateMgr   2014-05-01 23:01:38       5944 (0x1738)

UpdateURLWithTransportSettings(): NEW URL – http://SERVER/sms_dp_smspkg$/content_b0bd89d3-1cfb-4348-97bf-590f75672d0c.1            SrcUpdateMgr   2014-05-01 23:01:38       5944 (0x1738)

Added source http://server/sms_dp_smspkg$/content_b0bd89d3-1cfb-4348-97bf-590f75672d0c.1 from local dps to source list for product {1AD1DCA6-73FE-4803-858C-3441DF875BC1}   SrcUpdateMgr   2014-05-01 23:01:38            5944 (0x1738)

UpdateURLWithTransportSettings(): OLD URL – http://server/sms_dp_smspkg$/content_b0bd89d3-1cfb-4348-97bf-590f75672d0c.1            SrcUpdateMgr   2014-05-01 23:01:38       5944 (0x1738)

UpdateURLWithTransportSettings(): NEW URL – http://server/sms_dp_smspkg$/content_b0bd89d3-1cfb-4348-97bf-590f75672d0c.1            SrcUpdateMgr   2014-05-01 23:01:38       5944 (0x1738)

Added source http://SERVER/sms_dp_smspkg$/content_b0bd89d3-1cfb-4348-97bf-590f75672d0c.1 from local dps to source list for product {1AD1DCA6-73FE-4803-858C-3441DF875BC1}   SrcUpdateMgr   2014-05-01 23:01:38            5944 (0x1738)

Successfully updated source list for product {1AD1DCA6-73FE-4803-858C-3441DF875BC1}         SrcUpdateMgr   2014-05-01 23:01:38            5944 (0x1738)

Prerequisites;

Ensure that the software is available for the device.
Pre-requisites for Windows 7 (pre-SP1) clients; KB2619572

The following registry key:

HKLM\Software\Policies\Microsoft\Windows\Installer
Name: WinHttpAutoLogonLevel
Type: REG_SZ
Value: L

Never ending reboot prompts

A few servers had continue to request, through the ConfigMgr, a reboot after the servers were, surprise, rebooted due to Windows Updates.

Use the below PS code you can see the current state of the reboot.

Invoke-WmiMethod -Namespace “ROOT\ccm\ClientSDK” -Class CCM_ClientUtilities -Name DetermineIfRebootPending -ComputerName <name>

Obviously, before actually doing any of the below suggestions – a restart should before forced on the client. On the servers in question, a minimum of one reboot per server has been confirmed before using this workaround.

The WMI method apparently only retrieves the current value of the ConfigMgr-client. To change the state of the WMI method –  one has to digest the registry a bit;

clip_image001

The current state is saved in HKLM\Software\Microsoft\SMS\Mobile Client\Reboot Management\RebootData

On a server rename it to old, and then restarted the CCMEXEC-service.

To confirm that a request for a reboot you can either await the GUI initialization, or use the above PS code to verify the pending reboot state.

All Reboot requests are logged in RebootCoordinator.log in the ConfigMgr client log-folder.

Some interest articles;

http://blog.thesysadmins.co.uk/sccm-2012-stopping-your-computer-is-about-to-restart.html

http://blogs.technet.com/b/umairkhan/archive/2014/06/10/configmgr-client-reboot-internals-for-update-deployments.aspx

http://blogs.technet.com/b/heyscriptingguy/archive/2013/06/10/determine-pending-reboot-status-powershell-style-part-1.aspx

ConfigMgr Client installation failure

Two odd failures from the ConfigMgr client that caused some headaches.

StatusAgentProxy.dll fails to register.

Verify that MSVCR100.DLL in c:\windows\system32 (yes, on a 64-bit system aswell) is the correct size.The renamed file (MSVCR100old.dll) shows the size of 755kb – which most likely is installed by a 3-party application and in error replaced the correct version of the file. As you can see, both files have the same version number.

clip_image002

 

CcmRegisterPerfCounter fails with an unexpected error.

The custom action is intended to register the Performance Counters for ConfigMgr client. Basically it needs two files in c:\windows\system32 (ccmframework.h and ccmframework.ini), a few registry keys and then it can set it up. Performance Counters seems to be very stable so only a 3-party application can actually cause any havoc here. To resolve this perform the following;

Open MSCONFIG. Select the Startup-tab and click disable all.

clip_image002[5]

Select the Services-tab. Select to hide all Microsoft-services and then click the Disable all.

clip_image002[7]

Restart the computer, and verify that the installation will complete.

AppsNotify – Ping the Application Catalog

One downside with Configuration Manager 2012 compared to Configuration Manager 2007 is that applications deployed to users, only made available, will not give a notification on the endpoint. This caused some initial confusion that needed a blog-post to clarify the matter – as all admins were used to a single view on the client what could be installed.

Microsoft created the Application Catalog, and for newer devices (mobile and Windows 8+) they made a secondary interface called the Company Portal (read about deployment at Justin Chalfants blog). However, none of these give a notification to the end user if an application is only made available.

Therefore I created a script in PowerShell that can just do that – ping the Application Catalog – check if there are new apps, and if so notify the user.

How does it work?

A scheduled task is setup

image

It starts the application 15 minutes after logon (to avoid excessive workload..), and then runs every 15 minutes.

After that, the workflow is something like;

Connect to Application Catalog

  • If no previous check is completed, gather a list.No notification is given.
  • If a previous check is completed, compare it.
    • No difference? Do nothing
    • New applications available? Notify the user
      • Maintain a notification in the system tray (which directs the user to the Application Catalog if clicked)
    • Applications removed? Update the list, no notification to the end-user

 

For each check there is a log file within the users %TEMP% called appsnotify app.log. Sample output;

image

How does it look?

Like this;

appsnotify

 

How do I deploy it?

MSI-file to install it

To install it use the MSI file with the property APPCATALOG. Input should be;

APPCATALOG=http://localhost/CMApplicationCatalog

(no slash at the end)

The Application Catalog needs to be prepared for client interaction which is described by Microsoft in a blog-article. See the Getting Started section of Extending the Application Catalog in System Center 2012 Configuration Manager.

How do I make it my own?

In all Community spirit, here comes the code / files.

XML-file for creating the scheduled task – incase you want to build your own

&lt;?xml version=&quot;1.0&quot; encoding=&quot;UTF-16&quot;?&gt;
&lt;Task version=&quot;1.2&quot; xmlns=&quot;http://schemas.microsoft.com/windows/2004/02/mit/task&quot;&gt;
  &lt;RegistrationInfo&gt;
    &lt;Author&gt;PRECISION\Nicke&lt;/Author&gt;
  &lt;/RegistrationInfo&gt;
  &lt;Triggers&gt;
    &lt;LogonTrigger&gt;
      &lt;Repetition&gt;
        &lt;Interval&gt;PT15M&lt;/Interval&gt;
        &lt;StopAtDurationEnd&gt;false&lt;/StopAtDurationEnd&gt;
      &lt;/Repetition&gt;
      &lt;StartBoundary&gt;1899-12-30T06:04:14&lt;/StartBoundary&gt;
      &lt;Enabled&gt;true&lt;/Enabled&gt;
      &lt;Delay&gt;PT15M&lt;/Delay&gt;
    &lt;/LogonTrigger&gt;
  &lt;/Triggers&gt;
  &lt;Principals&gt;
    &lt;Principal id=&quot;Author&quot;&gt;
      &lt;GroupId&gt;S-1-5-32-545&lt;/GroupId&gt;
      &lt;RunLevel&gt;LeastPrivilege&lt;/RunLevel&gt;
    &lt;/Principal&gt;
  &lt;/Principals&gt;
  &lt;Settings&gt;
    &lt;MultipleInstancesPolicy&gt;IgnoreNew&lt;/MultipleInstancesPolicy&gt;
    &lt;DisallowStartIfOnBatteries&gt;false&lt;/DisallowStartIfOnBatteries&gt;
    &lt;StopIfGoingOnBatteries&gt;false&lt;/StopIfGoingOnBatteries&gt;
    &lt;AllowHardTerminate&gt;false&lt;/AllowHardTerminate&gt;
    &lt;StartWhenAvailable&gt;true&lt;/StartWhenAvailable&gt;
    &lt;RunOnlyIfNetworkAvailable&gt;true&lt;/RunOnlyIfNetworkAvailable&gt;
    &lt;IdleSettings&gt;
      &lt;StopOnIdleEnd&gt;true&lt;/StopOnIdleEnd&gt;
      &lt;RestartOnIdle&gt;false&lt;/RestartOnIdle&gt;
    &lt;/IdleSettings&gt;
    &lt;AllowStartOnDemand&gt;true&lt;/AllowStartOnDemand&gt;
    &lt;Enabled&gt;true&lt;/Enabled&gt;
    &lt;Hidden&gt;true&lt;/Hidden&gt;
    &lt;RunOnlyIfIdle&gt;false&lt;/RunOnlyIfIdle&gt;
    &lt;WakeToRun&gt;false&lt;/WakeToRun&gt;
    &lt;ExecutionTimeLimit&gt;PT0S&lt;/ExecutionTimeLimit&gt;
    &lt;Priority&gt;7&lt;/Priority&gt;
  &lt;/Settings&gt;
  &lt;Actions Context=&quot;Author&quot;&gt;
    &lt;Exec&gt;
      &lt;Command&gt;C:\Program Files (x86)\Common Files\AppsNotify\AppsNotify 2.0.exe&lt;/Command&gt;
      &lt;Arguments&gt;-appcatalog http://www.sample.com&lt;/Arguments&gt;
    &lt;/Exec&gt;
  &lt;/Actions&gt;
&lt;/Task&gt;

Source code for the PowerShell script. Wrapped it into a .exe with PowerShell Studio 2012. I have only tested this on Windows 7 x64 with PowerShell 3.0 / 2.0. Log-functions are from 9to5it

#========================================================================
# Created on: 2014-06-10
# Created by: Nicke Källén
# Organization:
# Filename: AppsNotify 2.0.pff
#========================================================================

$AppNotify.FormBorderStyle = 'FixedToolWindow'
Function Log-Start{
 &lt;#
 .SYNOPSIS
 Creates log file

 .DESCRIPTION
 Creates log file with path and name that is passed. Checks if log file exists, and if it does deletes it and creates a new one.
 Once created, writes initial logging data

 .PARAMETER LogPath
 Mandatory. Path of where log is to be created. Example: C:\Windows\Temp

 .PARAMETER LogName
 Mandatory. Name of log file to be created. Example: Test_Script.log

 .PARAMETER ScriptVersion
 Mandatory. Version of the running script which will be written in the log. Example: 1.5

 .INPUTS
 Parameters above

 .OUTPUTS
 Log file created

 .NOTES
 Version: 1.0
 Author: Luca Sturlese
 Creation Date: 10/05/12
 Purpose/Change: Initial function development

 Version: 1.1
 Author: Luca Sturlese
 Creation Date: 19/05/12
 Purpose/Change: Added debug mode support

 .EXAMPLE
 Log-Start -LogPath &quot;C:\Windows\Temp&quot; -LogName &quot;Test_Script.log&quot; -ScriptVersion &quot;1.5&quot;
 #&gt;

 [CmdletBinding()]

 Param ([Parameter(Mandatory=$true)][string]$LogPath, [Parameter(Mandatory=$true)][string]$LogName, [Parameter(Mandatory=$true)][string]$ScriptVersion)

 Process{
 $sFullPath = $LogPath + &quot;\&quot; + $LogName

 #Check if file exists and delete if it does
 If((Test-Path -Path $sFullPath)){
 Remove-Item -Path $sFullPath -Force
 }

 #Create file and start logging
 New-Item -Path $LogPath -Name $LogName –ItemType File

 Add-Content -Path $sFullPath -Value &quot;***************************************************************************************************&quot;
 Add-Content -Path $sFullPath -Value &quot;Started processing at [$([DateTime]::Now)].&quot;
 Add-Content -Path $sFullPath -Value &quot;***************************************************************************************************&quot;
 Add-Content -Path $sFullPath -Value &quot;&quot;
 Add-Content -Path $sFullPath -Value &quot;Running script version [$ScriptVersion].&quot;
 Add-Content -Path $sFullPath -Value &quot;&quot;
 Add-Content -Path $sFullPath -Value &quot;***************************************************************************************************&quot;
 Add-Content -Path $sFullPath -Value &quot;&quot;

 #Write to screen for debug mode
 Write-Debug &quot;***************************************************************************************************&quot;
 Write-Debug &quot;Started processing at [$([DateTime]::Now)].&quot;
 Write-Debug &quot;***************************************************************************************************&quot;
 Write-Debug &quot;&quot;
 Write-Debug &quot;Running script version [$ScriptVersion].&quot;
 Write-Debug &quot;&quot;
 Write-Debug &quot;***************************************************************************************************&quot;
 Write-Debug &quot;&quot;

 }
}

Function Log-Write{
 &lt;#
 .SYNOPSIS
 Writes to a log file

 .DESCRIPTION
 Appends a new line to the end of the specified log file

 .PARAMETER LogPath
 Mandatory. Full path of the log file you want to write to. Example: C:\Windows\Temp\Test_Script.log

 .PARAMETER LineValue
 Mandatory. The string that you want to write to the log

 .INPUTS
 Parameters above

 .OUTPUTS
 None

 .NOTES
 Version: 1.0
 Author: Luca Sturlese
 Creation Date: 10/05/12
 Purpose/Change: Initial function development

 Version: 1.1
 Author: Luca Sturlese
 Creation Date: 19/05/12
 Purpose/Change: Added debug mode support

 .EXAMPLE
 Log-Write -LogPath &quot;C:\Windows\Temp\Test_Script.log&quot; -LineValue &quot;This is a new line which I am appending to the end of the log file.&quot;
 #&gt;

 [CmdletBinding()]

 Param ([Parameter(Mandatory=$true)][string]$LogPath, [Parameter(Mandatory=$true)][string]$LineValue)

 Process{
 Add-Content -Path $LogPath -Value $LineValue

 #Write to screen for debug mode
 Write-Debug $LineValue
 }
}

Function Log-Error{
 &lt;#
 .SYNOPSIS
 Writes an error to a log file

 .DESCRIPTION
 Writes the passed error to a new line at the end of the specified log file

 .PARAMETER LogPath
 Mandatory. Full path of the log file you want to write to. Example: C:\Windows\Temp\Test_Script.log

 .PARAMETER ErrorDesc
 Mandatory. The description of the error you want to pass (use $_.Exception)

 .PARAMETER ExitGracefully
 Mandatory. Boolean. If set to True, runs Log-Finish and then exits script

 .INPUTS
 Parameters above

 .OUTPUTS
 None

 .NOTES
 Version: 1.0
 Author: Luca Sturlese
 Creation Date: 10/05/12
 Purpose/Change: Initial function development

 Version: 1.1
 Author: Luca Sturlese
 Creation Date: 19/05/12
 Purpose/Change: Added debug mode support. Added -ExitGracefully parameter functionality

 .EXAMPLE
 Log-Error -LogPath &quot;C:\Windows\Temp\Test_Script.log&quot; -ErrorDesc $_.Exception -ExitGracefully $True
 #&gt;

 [CmdletBinding()]

 Param ([Parameter(Mandatory=$true)][string]$LogPath, [Parameter(Mandatory=$true)][string]$ErrorDesc, [Parameter(Mandatory=$true)][boolean]$ExitGracefully)

 Process{
 Add-Content -Path $LogPath -Value &quot;Error: An error has occurred [$ErrorDesc].&quot;

 #Write to screen for debug mode
 Write-Debug &quot;Error: An error has occurred [$ErrorDesc].&quot;

 #If $ExitGracefully = True then run Log-Finish and exit script
 If ($ExitGracefully -eq $True){
 Log-Finish -LogPath $LogPath
 Break
 }
 }
}

Function Log-Finish{
 &lt;#
 .SYNOPSIS
 Write closing logging data &amp; exit

 .DESCRIPTION
 Writes finishing logging data to specified log and then exits the calling script

 .PARAMETER LogPath
 Mandatory. Full path of the log file you want to write finishing data to. Example: C:\Windows\Temp\Test_Script.log

 .PARAMETER NoExit
 Optional. If this is set to True, then the function will not exit the calling script, so that further execution can occur

 .INPUTS
 Parameters above

 .OUTPUTS
 None

 .NOTES
 Version: 1.0
 Author: Luca Sturlese
 Creation Date: 10/05/12
 Purpose/Change: Initial function development

 Version: 1.1
 Author: Luca Sturlese
 Creation Date: 19/05/12
 Purpose/Change: Added debug mode support

 Version: 1.2
 Author: Luca Sturlese
 Creation Date: 01/08/12
 Purpose/Change: Added option to not exit calling script if required (via optional parameter)

 .EXAMPLE
 Log-Finish -LogPath &quot;C:\Windows\Temp\Test_Script.log&quot;

 .EXAMPLE
 Log-Finish -LogPath &quot;C:\Windows\Temp\Test_Script.log&quot; -NoExit $True
 #&gt;

 [CmdletBinding()]

 Param ([Parameter(Mandatory=$true)][string]$LogPath, [Parameter(Mandatory=$false)][string]$NoExit)

 Process{
 Add-Content -Path $LogPath -Value &quot;&quot;
 Add-Content -Path $LogPath -Value &quot;***************************************************************************************************&quot;
 Add-Content -Path $LogPath -Value &quot;Finished processing at [$([DateTime]::Now)].&quot;
 Add-Content -Path $LogPath -Value &quot;***************************************************************************************************&quot;

 #Write to screen for debug mode
 Write-Debug &quot;&quot;
 Write-Debug &quot;***************************************************************************************************&quot;
 Write-Debug &quot;Finished processing at [$([DateTime]::Now)].&quot;
 Write-Debug &quot;***************************************************************************************************&quot;

 #Exit calling script if NoExit has not been specified or is set to False
 If(!($NoExit) -or ($NoExit -eq $False)){
 Exit
 }

 }
}

function Get-ScriptDirectory
{
if($hostinvocation -ne $null)
{
Split-Path $hostinvocation.MyCommand.path
}
else
{
Split-Path $script:MyInvocation.MyCommand.Path
}
} 

function Parse-Commandline
{
&lt;#
 .SYNOPSIS
 Parses the Commandline of a package executable

 .DESCRIPTION
 Parses the Commandline of a package executable

 .PARAMETER Commandline
 The Commandline of the package executable

 .EXAMPLE
 $arguments = Parse-Commandline -Commandline $Commandline

 .INPUTS
 System.String

 .OUTPUTS
 System.Collections.Specialized.StringCollection
#&gt;

 [OutputType([System.Collections.Specialized.StringCollection])]
 Param([string]$CommandLine) 

 $Arguments = New-Object System.Collections.Specialized.StringCollection 

 if($CommandLine)
 {
 #Find First Quote
 $index = $CommandLine.IndexOf('&quot;') 

 while ( $index -ne -1)
 {#Continue as along as we find a quote
 #Find Closing Quote
 $closeIndex = $CommandLine.IndexOf('&quot;',$index + 1)
 if($closeIndex -eq -1)
 {
 break #Can’t find a match
 }
 $value = $CommandLine.Substring($index + 1,$closeIndex – ($index + 1))
 [void]$Arguments.Add($value)
 $index = $closeIndex 

 #Find First Quote
 $index = $CommandLine.IndexOf('&quot;',$index + 1)
 }
 }
 return $Arguments
}

function Convert-CommandLineToDictionary
{
 &lt;#
 .SYNOPSIS
 Parses and converts the commandline of a packaged executable into a Dictionary

 .DESCRIPTION
 Parses and converts the commandline of a packaged executable into a Dictionary

 .PARAMETER Dictionary
 The Dictionary to load the value pairs into.

 .PARAMETER CommandLine
 The commandline of the package executable

 .PARAMETER ParamIndicator
 The character used to indicate what is a parameter.

 .EXAMPLE
 $Dictionary = New-Object System.Collections.Specialized.StringDictionary
 Convert-CommandLineToDictionary -Dictionary $Dictionary -CommandLine $Commandline -ParamIndicator '-'
 #&gt;
 Param( [ValidateNotNull()]
 [System.Collections.Specialized.StringDictionary]$Dictionary,
 [string]$CommandLine,
 [char] $ParamIndicator) 

 $Params = Parse-Commandline $CommandLine

 for($index = 0; $index -lt $Params.Count; $index++)
 {
 [string]$param = $Params[$index]
 #Clear the values
 $key = &quot;&quot;
 $value = &quot;&quot; 

 if($param.StartsWith($ParamIndicator))
 {
 #Remove the indicator
 $key = $param.Remove(0,1)
 if($index + 1 -lt $Params.Count)
 {
 #Check if the next Argument is a parameter
 [string]$param = $Params[$index + 1]
 if($param.StartsWith($ParamIndicator) -ne $true )
 {
 #If it isn’t a parameter then set it as the value
 $value = $param
 $index++
 }
 }
 $Dictionary[$key] = $value
 }#else skip
 }
}

function Validate-IsURL
{
 &lt;#
 .SYNOPSIS
 Validates if input is an URL

 .DESCRIPTION
 Validates if input is an URL

 .PARAMETER Url
 A string containing an URL address

 .INPUTS
 System.String

 .OUTPUTS
 System.Boolean
 #&gt;
 [OutputType([Boolean])]
 param ([string]$Url)

 if($Url -eq $null)
 {
 return $false
 }

 return $Url -match &quot;^(ht|f)tp(s?)\:\/\/[0-9a-zA-Z]([-.\w]*[0-9a-zA-Z])*(:(0-9)*)*(\/?)([a-zA-Z0-9\-\.\?\,\'\/\\\+&amp;amp;%\$#_]*)?$&quot;
}

function Get-CMUserApps {
 [CmdletBinding()]
 param
 (
 [Parameter(Mandatory=$True,
 ValueFromPipelineByPropertyName=$True,
 HelpMessage='URL for Application Catalogue')]
 $url,
 [Parameter(Mandatory=$True,
 ValueFromPipelineByPropertyName=$True,
 HelpMessage='Path to logfile')]
 $logfile,
 [Parameter(Mandatory=$True,
 ValueFromPipelineByPropertyName=$True,
 HelpMessage='Temp-file')]
 $temp
 )
 Begin {
 log-write -LogPath $logfile -LineValue &quot;Create web service proxy&quot;
 $catalogurl = $url;
 Log-Write -LogPath $logfile -LineValue &quot;Connecting to $catalogurl&quot;
 try {
 $url = $catalogurl+&quot;/ApplicationViewService.asmx?WSDL&quot;;
 $service = New-WebServiceProxy $url -UseDefaultCredential;

 }
 catch {
 Log-Error -LogPath $logfile -ErrorDesc &quot;AppCatalog no response&quot; -ExitGraceFully $false
 Log-Finish -LogPath $logfilePath -NoExit $true
 break
 }
 }
 Process {

 $total = 0;
 try {
 Log-Write -LogPath $logfile -LineValue &quot;Gathering applications&quot;
 $service.GetApplications(&quot;Name&quot;,$null,&quot;Name&quot;,&quot;&quot;,100,0,$true,&quot;PackageProgramName&quot;,$false,$null,[ref]$total) | select ApplicationId,Name | Export-Clixml $temp
 return $true
 }

 catch {
 Log-Error -LogPath $logfile -ErrorDesc $error[0] -ExitGraceFully $false
 return $false
 }
 Remove-Variable -Name url
 Remove-Variable -Name total
 $service.dispose()

 }

}

function Compare-CMUserApps {
 [CmdletBinding()]
 param
 (
 [Parameter(Mandatory=$True,
 ValueFromPipelineByPropertyName=$True,
 HelpMessage='Permanent-file')]
 $file,
 [Parameter(Mandatory=$True,
 ValueFromPipelineByPropertyName=$True,
 HelpMessage='Temp-file')]
 $temp,
 [Parameter(Mandatory=$True,
 ValueFromPipelineByPropertyName=$True,
 HelpMessage='Path to logfile')]
 $logfile
 )
Process {
 Log-Write -LogPath $logfile -LineValue &quot;Comparing applications lists&quot;
 If (-Not (Test-Path $file)) {
 Log-Write -LogPath $logfile -LineValue &quot;No previous version of apps list&quot;
 try {
 Rename-Item $temp &quot;$prefix apps.xml&quot;
 }
 catch {
 Remove-Item $temp
 Log-Error -LogPath $logfile -ErrorDesc &quot;Unable to create initial list&quot; -ExitGracefully $false
 }

 }
 Else {

 Log-Write -LogPath $logfile -LineValue &quot;Starting check......&quot;
 # $diffs = (Compare-Object -ReferenceObject $(Get-Content $file) -DifferenceObject $(Get-Content $temp)) | Where {$_.SideIndicator -eq '&lt;='}
 # $diffsserver = (Compare-Object -ReferenceObject $(Get-Content $file) -DifferenceObject $(Get-Content $temp)) | Where {$_.SideIndicator -eq '=&gt;'}

 If ((Compare-Object -ReferenceObject $(Get-Content $file -ReadCount 0) -DifferenceObject $(Get-Content $temp -ReadCount 0)) -eq $null) {
 Log-Write -LogPath $logfile -LineValue &quot;No new applications&quot;
 Log-Write -LogPath $logfile -LineValue &quot;Removing temporary file&quot;

 try {
 Remove-Item $temp
 }
 catch {

 Log-Error -LogPath $logfile -ErrorDesc &quot;Unable to remove temp list&quot; -ExitGracefully $false
 }

 }
 Elseif (((Compare-Object -ReferenceObject $(Get-Content $file -ReadCount 0) -DifferenceObject $(Get-Content $temp -ReadCount 0)) | Where {$_.SideIndicator -eq '&lt;='}) -ne $null -and ((Compare-Object -ReferenceObject $(Get-Content $file -ReadCount 0) -DifferenceObject $(Get-Content $temp -ReadCount 0)) | Where {$_.SideIndicator -eq '=&gt;'}) -eq $null ) {
 Log-Write -LogPath $logfile -LineValue &quot;Less applications received&quot;
 try {
 Log-Write -LogPath $logfile -LineValue &quot;Remove permanent list&quot;
 Remove-Item $file
 }
 catch {
 Remove-Item $temp
 Log-Error -LogPath $logfile -ErrorDesc &quot;Unable to remove permanent list&quot; -ExitGracefully $false
 }

 try {
 Log-Write -LogPath $logfile -LineValue &quot;Rename temporary list&quot;
 Rename-Item $temp &quot;$prefix apps.xml&quot;
 }
 catch {
 Log-Error -LogPath $logfile -ErrorDesc &quot;Unable to switch temp-list to permanent&quot; -ExitGracefully $false
 }

 }

 Else {
 Log-Write -LogPath $logfile -LineValue &quot;New applications found&quot;

 $newapps = $true

 }

 }
 If ($newapps -eq $true) {
 return $True

 }

 }

}

function OnApplicationLoad {
 #Note: This function is not called in Projects
 #Note: This function runs before the form is created
 #Note: To get the script directory in the Packager use: Split-Path $hostinvocation.MyCommand.path
 #Note: To get the console output in the Packager (Windows Mode) use: $ConsoleOutput (Type: System.Collections.ArrayList)
 #Important: Form controls cannot be accessed in this function
 #TODO: Add snapins and custom code to validate the application load

 return $true #return true for success or false for failure
}

function OnApplicationExit {
 #Note: This function is not called in Projects
 #Note: This function runs after the form is closed
 #TODO: Add custom code to clean up and unload snapins when the application exits
 #Log-Finish -LogPath $logfilePath -NoExit $true
 $script:ExitCode = 0 #Set the exit code for the Packager
 Log-Finish -LogPath $logfilePath -NoExit $false
 break
}

$AppNotify_Load={
 #TODO: Initialize Form Controls here
 $NotifyIcon.ShowBalloonTip(30000,&quot;New Application&quot;,&quot;You have new applications available&quot;, 'Info')
}

#region Control Helper Functions
function Show-NotifyIcon
{
&lt;#
 .SYNOPSIS
 Displays a NotifyIcon's balloon tip message in the taskbar's notification area.

 .DESCRIPTION
 Displays a NotifyIcon's a balloon tip message in the taskbar's notification area.

 .PARAMETER NotifyIcon
 The NotifyIcon control that will be displayed.

 .PARAMETER BalloonTipText
 Sets the text to display in the balloon tip.

 .PARAMETER BalloonTipTitle
 Sets the Title to display in the balloon tip.

 .PARAMETER BalloonTipIcon
 The icon to display in the ballon tip.

 .PARAMETER Timeout
 The time the ToolTip Balloon will remain visible in milliseconds.
 Default: 0 - Uses windows default.
#&gt;
 param(
 [Parameter(Mandatory = $true, Position = 0)]
 [ValidateNotNull()]
 [System.Windows.Forms.NotifyIcon]$NotifyIcon,
 [Parameter(Mandatory = $true, Position = 1)]
 [ValidateNotNullOrEmpty()]
 [String]$BalloonTipText,
 [Parameter(Position = 2)]
 [String]$BalloonTipTitle = '',
 [Parameter(Position = 3)]
 [System.Windows.Forms.ToolTipIcon]$BalloonTipIcon = 'None',
 [Parameter(Position = 4)]
 [int]$Timeout = 0
 )

 if($NotifyIcon.Icon -eq $null)
 {
 #Set a Default Icon otherwise the balloon will not show
 $NotifyIcon.Icon = [System.Drawing.Icon]::ExtractAssociatedIcon([System.Windows.Forms.Application]::ExecutablePath)
 }

 $NotifyIcon.ShowBalloonTip($Timeout, $BalloonTipTitle, $BalloonTipText, $BalloonTipIcon)
}

#endregion

$NotifyIcon_MouseDoubleClick=[System.Windows.Forms.MouseEventHandler]{
#Event Argument: $_ = [System.Windows.Forms.MouseEventArgs]
 #TODO: Place custom script here
 Log-Write -LogPath $logfilePath -LineValue &quot;User clicked icon&quot;
 Log-Write -LogPath $logfilePath -LineValue &quot;Sending user to $appcatalog&quot;
 Start-Process $appcatalog
 $NotifyIcon.Visible = $false

 try {
 Log-Write -LogPath $logfilePath -LineValue &quot;Removing $filepath&quot;
 Remove-Item $filePath
 Log-Write -LogPath $logfilePath -LineValue &quot;Renaming $tempfilePath&quot;
 Rename-Item -Path &quot;$tempfilePath&quot; -NewName &quot;$prefix apps.xml&quot; -Force

 }
 catch {
 Remove-Item $tempfilePath
 Log-Error -LogPath $logfile -ErrorDesc &quot;Unable to remove permanent list&quot; -ExitGracefully $false
 }
 $AppNotify.Close()
 $NotifyIcon.Dispose()
 #Log-Finish -LogPath $logfilePath -NoExit $false
 #$AppNotify.Close()
 #$timer1.Start()

}

$NotifyIcon_MouseClick=[System.Windows.Forms.MouseEventHandler]{
#Event Argument: $_ = [System.Windows.Forms.MouseEventArgs]
 $NotifyIcon.Visible = $true
 $NotifyIcon.ShowBalloonTip(30000,&quot;New Application&quot;,&quot;You have new applications available&quot;, 'Info')
}

$NotifyIcon_BalloonTipClicked={
 Log-Write -LogPath $logfilePath -LineValue &quot;User clicked ballontip&quot;
 Log-Write -LogPath $logfilePath -LineValue &quot;Sending user to $appcatalog&quot;
 Start-Process $appcatalog
 $NotifyIcon.Visible = $false

 try {
 Log-Write -LogPath $logfilePath -LineValue &quot;Removing $filepath&quot;
 Remove-Item $filePath
 Log-Write -LogPath $logfilePath -LineValue &quot;Renaming $tempfilePath&quot;
 Rename-Item -Path &quot;$tempfilePath&quot; -NewName &quot;$prefix apps.xml&quot; -Force

 }
 catch {
 Remove-Item $tempfilePath
 Log-Error -LogPath $logfile -ErrorDesc &quot;Unable to remove permanent list&quot; -ExitGracefully $false
 }
 $AppNotify.Close()
 $NotifyIcon.Dispose()
 #Log-Finish -LogPath $logfilePath -NoExit $true
 #exit
 #$timer1.Start()
}

#Get path which scripts run from
$CurrentPath = Get-ScriptDirectory

#Prefix for all generated files in user's %TEMP%
$prefix = &quot;appsnotify&quot;

#Logfile
$logfilePath = $env:temp+&quot;\$prefix app.log&quot;

$check=Get-Process AppsNotify -ErrorAction SilentlyContinue | Measure-Object

if ($check.count -lt &quot;2&quot;) {

}
else {
 Log-Error -LogPath $logfilePath -ErrorDesc &quot;AppsNotify is already running. Terminating. &quot; -ExitGraceFully $false
 exit
}

#Temporary file to store applications
$tempfilePath = $env:temp+&quot;\$prefix app_temp.xml&quot;
#Permanent file to store applications
$filePath = $env:temp +&quot;\$prefix apps.xml&quot;
#Reset log-file for this session
Remove-Item $logfilePath

################################################################################################################
Log-Start -LogPath $env:temp -LogName &quot;$prefix app.log&quot; -ScriptVersion &quot;2.0&quot;

#Verify that the $CommandLine variable exists
if($CommandLine -ne $null -and $CommandLine -ne &quot;&quot;)
{
 #Log-Write -LogPath $logfilePath -LineValue &quot;There is a command-line&quot;
 Log-Write -LogPath $logfilePath -LineValue &quot;Command-line is:&quot;
 Log-Write -LogPath $logfilePath -LineValue &quot;$CommandLine&quot;
 #$Arguments = Parse-Commandline $CommandLine
 #Convert the Arguments. Use – as the Argument Indicator
 $Dictionary = New-Object System.Collections.Specialized.StringDictionary
 Convert-CommandLineToDictionary -Dictionary $Dictionary -CommandLine $Commandline -ParamIndicator '-'
}
else
{
 #Not running in a packager or no command line arguments passed
 Log-Error -LogPath $logfilePath -ErrorDesc &quot;No command-line argument. Use -appcatalog &lt;url&gt;&quot; -ExitGraceFully $false
 Log-Finish -LogPath $logfilePath -NoExit $false
 break
}

$appcatalog = $Dictionary[&quot;appcatalog&quot;]
if($appcatalog -ne $null -and $appcatalog -ne &quot;&quot;) {
 Log-Write -LogPath $logfilePath -LineValue &quot;Passed Application Catalog is $appcatalog&quot;
 if (Validate-IsURL -Url $appcatalog) {
 Log-Write -LogPath $logfilePath -LineValue &quot;Passed Application Catalog is a URL&quot;
 }
 Else {
 Log-Error -LogPath $logfilePath -ErrorDesc &quot;This is not a url&quot; -ExitGraceFully $false
 Log-Finish -LogPath $logfilePath -NoExit $false
 break
 }

}
else {
 #Address to Application Catalogue
 Log-Error -LogPath $logfilePath -ErrorDesc &quot;We need an Application Catalog&quot; -ExitGraceFully $false
 Log-Finish -LogPath $logfilePath -NoExit $false
 break
}

if ((Get-CMUserApps -url $appcatalog -logfile $logfilePath -temp $tempfilePath) -eq $true) {

 if ((Compare-CMUserApps -file $filePath -temp $tempfilePath -logfile $logfilePath) -eq $true) {

 try {
 $NotifyIcon.Visible = $true
 }
 catch {
 Log-Write -LogPath $logfilePath -LineValue &quot;Exception&quot;
 }
 $NotifyIcon.ShowBalloonTip(30000,&quot;New Application&quot;,&quot;You have new applications available&quot;, 'Info')

 }
 Else {
 Log-Finish -LogPath $logfilePath -NoExit $false
 break
 }

}
Else {
 Log-Finish -LogPath $logfilePath -NoExit $false
 break
}

$NotifyIcon_BalloonTipShown={
 #TODO: Place custom script here
 Log-Write -LogPath $logfilePath -LineValue &quot;Notifying user&quot;
}

} 

Create Application

During a project I created a specific tool to ease the insert of applications within SCCM 2012. It was only tested for SCCM 2012 SP1 CU3 and on Windows 7 x64, with Windows Management Framework 3.0.

Video that demonstrates the tool; Create Application

David O`Brien wrote some great code that sorts out the poor quality of the SCCM 2012 PowerShell support. It is used as part of the script process.

Luca Sturlese made a logging function for PowerShell available which I used to create the log-file.

The idea is that you copy the source-files to your source-folder for all your apps, right-click the MSI and then the script creates an application within SCCM 2012 based on the MSI information. A log-file is generated incase some troubleshooting is necessary, but for status you can simply review the notification bubble.

image

I haven’t tested this on SCCM 2012 R2. I code horribly. I know the installer breaks on a x86 platform. It most likely works on Windows 8+, but I haven’t tested that. The script is depending on multiple WMI-calls, which in high latency environments will cause issues.

You are welcome to try this, send me improvements – or even take this for a spin and just make it better. I was tired of bitcoin casino doing this manually, and copy and pasted something from the internet which solved my problem. Take this for what it is. Nothing more, nothing less.

Some tips for installing the MSI;

Use the following properties;
SITESERVER
FQDN to site-server
BASEFOLDER
Basefolder where all packages will be created. A new sub-folder with username will be created
USEFOLDERS
Base Application Name on MSI-info (false) or folder-name
(true – requires; APPNAME\DEPLOYMENT folder structure)

LOGONREQUIREMENTTYPE
True or False
ALLOWCLIENTSTOUSEFALLBACKSOURCELOCATIONFORCONTENT
True or False
ESTIMATEDINSTALLATIONTIME
Numbers only
FALLBACKTOUNPROTECTEDDP
True or False
MAXIMUMALLOWEDRUNTIMEMINUTES
Numbers only
ONSLOWNETWORKMODE
Download or DoNothing
INSTALLATIONLOG
Path and prefix to where log-files will be located.
ProductName and ProductVersion will be appended
UNINSTALLLOG
Path and prefix to where log-files will be located.
ProductName and ProductVersion will be appended

Logging happens to:
%TEMP%\a_createapp.log

Download the file here

SCCM 2012, Software Center and applications made available to users

As noted quite early in the marketing for SCCM 2012 there was a change on how users were presented with available applications. The fundamental change is that any software made available (as opposed to required) were only presented in the new Application Catalog. Microsoft posted a blog-article detailing the different scenarios that could happen, which presents a good overview table on what is seen where when deployed to different resources.

As opposed to start communicating users to either use Application Catalog or Software Center depending on how we have decided to target the users or the computers within SCCM, a better way would be to leverage the traditional method of targeting computers. If targeting computers any software made available will be presented in Software Center, however SCCM 2012 has also been released with the mindset to start handling users – and not computers.

What to do?

Use SCCM to target software to the user’s devices. This would avoid the following topics in the forums; user-based available application not showing up in Software Center by design?!

Previously a few blog-articles have discussed this method for targeting Software Updates and a few rare occasions have shown up with no specific purpose.

To boil it down we will be leveraging the fact that SCCM can track users primary devices and thereby maintaining a relationship between devices and users. This method will allow us to query the database for the following information;

All the devices for users belonging to AD-group X.

Create a collection with the below query and this will allow you to provision software to users, but target their computers.

select SMS_R_SYSTEM.ResourceID,
SMS_R_SYSTEM.ResourceType,
SMS_R_SYSTEM.Name,
SMS_R_SYSTEM.SMSUniqueIdentifier,
SMS_R_SYSTEM.ResourceDomainORWorkgroup,
SMS_R_SYSTEM.Client
from SMS_R_System
JOIN SMS_UserMachineRelationship ON
SMS_R_System.Name=SMS_UserMachineRelationship.ResourceName
JOIN SMS_R_User ON
SMS_UserMachineRelationship.UniqueUserName = SMS_R_User.UniqueUserName
WHERE   SMS_UserMachineRelationship.Types=1
AND   SMS_R_User.UserGroupName="DOMAIN\\AD-GROUP"

Apply hotfixes during task sequence

If you want to apply hotfixes as part of a task sequence there are of course numerous ways to achieve this. Using PowerShell, MDT 2012 U1 (soon to be 2013??) with SCCM 2012 can make this look a lot prettier with just a few lines of code.

So, lets set this up.

1. First of all we need a script, named hotfixes.ps1. The code looks like this;

$scriptPath = split-path -parent $MyInvocation.MyCommand.Definition
$folder = $scriptPath
$total = gci $folder *.msu | measure | Select-Object -expand Count
$i = 0
gci $folder *.msu | foreach {
$i++
WUSA ""$_.FullName /quiet /norestart""
Write-Progress -activity "Applying Hotfixes" -status "Installing $_ .. $i of $total" -percentComplete (($i / $total) * 100)
Wait-Process -name wusa
}

2. Collect the hotfixes necessary into a folder. Place the script in the root, and the hotfixes in a sub-folder named “hotfix”. All hotfixes must be of the .MSU-kind.

image

3. Create a package of this to make the files available within the SCCM 2012 infrastructure

image

4. Create a new MDT 2012 Update 1 task sequence. Add the following steps;

image

As you can see below we set our script to be executed and reference the package which contains the updates

image

5. Deploy the task sequence to a computer and verify the results;

image

Looks nice? Well, the script is partly built on the ideas of Niklas Åkerlund, a fellow Swede, on howto install multiple Windows hotfixes. Using a forum-post response by Michael Niehaus I gathered that it would be rather simple to output it more nicely and provide insight into progress using the task sequence progress bar.