Excel Add-in Automation

This is a replica (with better formatting) or a post I made over at ITNinja (or AppDeploy back then!) several years ago.  I’m not even sure if it still works but it’s great as a reference point, so use it at you own risk.

This 5-step tutorial enables excel add-ins (XLA, XLL, XLAM) to be automatically installed/removed if they are included in your Windows Installer package. The Custom Actions (CA) will either install EVERY excel add-in in the installer, or just add-ins in a specific directory.

The first version struggled with COM add-ins (worked best for automation add-ins) whereas this version should also work well with COM add-ins. This version also:

  • removes the add-in for multiple office versions (97 To 2010)
  • removes the add-in for multiple profiles IF DESIRED
  • installs XLAM add-ins

It still outputs any debugging messages to the windows installer log file. Each debugging line starts with either ‘AddExcelAddinStatus’ or ‘RemoveExcelAddinStatus:’.

This version contains separate CAs for adding and removing the add-in, because when adding/removing COM add-ins the automation needs to be placed in specific parts of the IESequence.

Step 1 – Create Two Properties

  • Create a property called ‘installAddin’. Give it a default value. I gave mine ‘noaddin’ (It doesn’t really matter what default value you give it)
  • Create a property called ‘removeAddin’. Give it a default value. I gave mine ‘noaddin’

Step 2 – Create Custom Action for Add-In Selection

We can either install every single add-in in the installer, or only install the add-ins which are present in a specified directory (See red font in code)

  • Create a type 38 CA (Embedded VBScript). Call it ‘setAddinProperty’.
  • Schedule it as Immediate, just before InstallInitialize. Do not give it a condition. We want it to Execute on install, uninstall and repair.

Paste the following code into your CA (You should only need to edit the values of blnfilterByDirectory and/or filterDirectory. LEAVE EVERYTHING ELSE ALONE.):

'set blnfilterByDirectory to True if you want to install all add-ins in a specific directory (also specify the directory name below)
'set blnfilterByDirectory to False if you want to install every single add-in in the Installer
Dim blnfilterByDirectory : blnfilterByDirectory = True
'***Important - This directory name is case-sensitive!!!
Dim filterDirectory : filterDirectory = "INSTALLDIR"

'*************************************
'*****DO NOT EDIT BELOW THIS LINE
'*************************************

Dim tempFileName : tempFileName = ""
Dim tempComponent : tempComponent = ""
Dim addinList : addinList = ""
Dim tempExtension : tempExtension = ""

'If we're filtering by directory, construct the sql command accordingly
If blnfilterByDirectory Then
	sql = "SELECT File.Component_,File.FileName,Component.Directory_ FROM File, Component WHERE File.Component_ = Component.Component AND Component.Directory_ = '" & filterDirectory & "'"
Else
	sql = "SELECT File.Component_,File.FileName,Component.Directory_ FROM File, Component WHERE File.Component_ = Component.Component"
End If

'start searching through file table for add-ins (.XLA or .XLL files)
Set fileView= Session.Database.OpenView(sql)
fileView.Execute
Set fileRecord = fileView.Fetch
While Not fileRecord Is Nothing
	tempFileName = LCase(fileRecord.StringData(2))
	If InStr(tempFileName,"|") Then 'if filename is currently in sfn form, try and retrieve the full file name
		tempFileName = Split(tempFileName,"|")(1)
	End If
	If InStr(tempFileName,".") Then
		tempExtension = Split(tempFileName,".")(1)
	End If
	
	If (tempExtension = "xla" Or tempExtension = "xll" Or tempExtension = "xlam") Then 'its an excel addin
'construct list of addins, delimited by commas
		addinList = addinList & Session.Property(fileRecord.StringData(3)) & tempFileName & ","
	End If
	Set fileRecord = fileView.Fetch
Wend

Set fileView = Nothing
Set fileRecord = Nothing

'remove trailing comma
If Len(addinList) > 0 Then
	addinList = Left(addinList,Len(addinList)-1)
End If

Property("installAddin") = CStr(addinList)
Property("removeAddin") = CStr(addinList)

'update windows installer session environment and current process with any
'path environment variables found in environment table

Dim tempName : tempName = ""
Dim tempValue : tempValue = ""
Dim tempEnvPath : tempEnvPath = ""


sql = "SELECT Name, Value FROM Environment"

Set envView= Session.Database.OpenView(sql)
envView.Execute
Set envRecord = envView.Fetch
While Not envRecord Is Nothing
	
	tempName = envRecord.StringData(1)
	tempValue = envRecord.StringData(2)
	
	If Not Instr(tempName,"!") > 0 Then
'if we're not removing env var on installation
		
		tempName = replace(tempName,"=","")
		tempName = replace(tempName,"+","")
		tempName = replace(tempName,"-","")
		tempName = replace(tempName,"*","")
		
		If lcase(tempName) = "path" Then
			
			If right(tempValue,3) = "[~]" Then
'prefix
				tempValue = replace(tempValue,"[~]","")
				tempEnvPath = returnEnvironmentPath(tempValue) & ";" & Session.Installer.Environment("Path")
			ElseIf left(tempValue,3) = "[~]" Then
'suffix
				tempValue = replace(tempValue,"[~]","")
				tempEnvPath = Session.Installer.Environment("Path") & ";" & returnEnvironmentPath(tempValue)
			Else
'replacement, which 'should' never happen with the path var, but for this we'll set as prefix
				tempEnvPath = returnEnvironmentPath(tempValue) & ";" & Session.Installer.Environment("Path")
			End If
'replace any double-semis
			tempEnvPath = replace(tempEnvPath,";;",";")
'set session env path
			Session.Installer.Environment("Path") = tempEnvPath
			
'make the relevant Path env var available to current process (and processes spawned therein)
			Set oShell = CreateObject("WScript.Shell")
			Set oProcessEnv = oShell.Environment("PROCESS")
			oProcessEnv("Path") = tempEnvPath
			Set oProcessEnv = Nothing
			Set oShell = Nothing
			
			
			
		End If
	End If
	
	Set envRecord = envView.Fetch
Wend

Set envView = Nothing
Set envRecord = Nothing



'Function to return 'proper' path for env var
Function returnEnvironmentPath(envPath)
	
	Set objRE = New RegExp
	With objRE
		.Pattern = "\[.+\]" 'match anything inside and including square brackets Eg [WindowsVolume]
		.IgnoreCase = True
		.Global = False 'return one instance
	End With
	
' Test method returns TRUE if a match is found
	If objRE.Test(envPath) Then
		
		Set objMatch = objRE.Execute(envPath)
		strProperty = objMatch.Item(0)
		Set objMatch = Nothing
'perform the replacement
		strEnvPath = objRE.Replace(envPath, Session.Property(Mid(strProperty,2,Len(strProperty)-2)))
		returnEnvironmentPath = strEnvPath
	Else
		returnEnvironmentPath = envPath
	End If
	
	Set objRE = Nothing
	
End Function

Step 3 – Create CA to install addin

  • Create another Type 38 CA. Call it ‘installAddin’.
  • Schedule it straight after ScheduleReboot, Deferred in a User Context (Setting it as deferred etc makes the Type become 1062 in your CA table).
    Give it a condition of:

NOT Installed Or MaintenanceMode=”Modify”

  • Paste the following code into your CA:
'*************************************
'logic to install addin (can be used for automation addins or COM addins)
'(All status messages are printed to installer log)
'(All log status entries start with 'AddExcelAddinStatus: {status}')
'*************************************

Dim blnReturn : blnReturn = False
Dim objXL
Dim objWorksheet
Dim objAddin
Dim strAddIn : strAddIn = ""
Dim strMsg : strMsg = ""
Dim strAddInName : strAddInName = ""
Dim addinList : addinList = ""
Dim addinListArray : addinListArray = ""
Dim i : i = 0
Const HKEY_LOCAL_MACHINE = &H80000002
Const HKEY_CURRENT_USER = &H80000001
Dim strFirstRun : strFirstRun = ""
Dim strUserData : strUserData = ""
Dim strFirstRunValueName : strFirstRunValueName = ""
Dim blnFoundFirstRun : blnFoundFirstRun = False
Dim dwValue : dwValue = ""
Dim strComputer : strComputer = "."
Dim objRegistry
Dim officeversion
Dim keyCount : keyCount = 0
Dim keyArray(14)
Dim valueCount : valueCount = 0
'cannot redim a multi-dim array so we set the size statically
Dim valueArray(9,1)


'retrieve the value of the property we set earlier
'(The value is comma-separated in the form 'featureInstallState, Addin1, Addin2, Addin3......' etc)
addinList = Session.Property("CustomActionData")
'write value of Session Property to log for debugging purposes
writeToLog("Deferred property contains: " & addinList)
If Len(addinList) > 0 Then 'if we found an add-In
	Set objRegistry= GetObject("winmgmts:\\" & strComputer & "\root\default:StdRegProv")
	
'see if Excel has been opened before
	For officeversion = 8 to 14
		strFirstRun = "Software\Microsoft\Office\" & officeversion & ".0\Excel\Options"
		
		objRegistry.EnumValues HKEY_CURRENT_USER, strFirstRun, arrValueNames, arrValueTypes
'check if a value is returned
		If IsArray(arrValueNames) Then
'if so, loop through values in the registry key
			For a=0 To UBound(arrValueNames)
				strFirstRunValueName = arrValueNames(a)
'if the value is 'FirstRun', read it
				If UCase(strFirstRunValueName) = "FIRSTRUN" Then
					objRegistry.GetDWORDValue HKEY_CURRENT_USER,strFirstRun,strFirstRunValueName,dwValue
'if value is not zero, it's not been run for the first time, so we automate it
					If CInt(dwValue) <> 0 Then
						writeToLog("Excel has not been run for the first time....Firstrun value exists but is not '0'. Setting UserData value to 1....")
					End If
'foudn a firstrun entry
					blnFoundFirstRun = True
				End If
			Next
		End If
	Next
	Set objRegistry= Nothing
	
	If Not blnFoundFirstRun Then
'havent found any firstrun value, so excel has not been run
		writeToLog("Excel has not been run for the first time....Firstrun value does not exist. Attempting to set UserData value....")
		setUserData()
	End If
	
	
'retrieve addin list
	addinListArray = split(addinList,",")
	
'for every addin, try and add it
	For i = 0 To UBound(addinListArray)
		
'get individual addin full path
		strAddInName = Trim(addinListArray(i))
		
		blnReturn = AddExcelAddin(strAddInName)
		If Not blnReturn Then
			strMsg = "Unable to install Excel add-in '" & strAddInName & "'"
			writeToLog(strMsg)
		End If
	Next
	
	
	If Not blnFoundFirstRun Then
'resets registry keys so Excel heals agian on first launch
		revertRegistry()
	End If
	
	
Else
	strMsg = "No add-ins were found. If you are installing add-ins from a specific directory, check the case of your specified directory in the setAddinProperty CA."
	writeToLog(strMsg)
End If

'create and delete a system environment variable to ensure any system environment vars installed with the package
'successfully update on the target system without a reboot

Set wshshell = CreateObject("WScript.Shell")
Set WshSysEnv = wshShell.Environment("SYSTEM")

WshSysEnv("FlushEnvironment") = "default"
WshSysEnv.Remove("FlushEnvironment")

Set WshSySEnv = Nothing
Set wshshell = Nothing

Function setUserData()
	
'If we write UserData value, Excel will not self-heal if it has not been loaded before. However, if we keep
'the FirstRun value as either not existing, or set to a non-zero value, Excel will still heal when manually loaded.
	
	Set objRegistry= GetObject("winmgmts:\\" & strComputer & "\root\default:StdRegProv")
	
	For oversion = 8 To 14
		strUserData = "Software\Microsoft\Office\" & oversion & ".0\"
		If objRegistry.EnumKey (HKEY_LOCAL_MACHINE, strUserData & "Excel", arrValueNames) = 0 Then
'if reg key exists, write UserData in HKCU
'create registry key
			createRegistryKey HKEY_CURRENT_USER, strUserData & "Excel"
'write dword value
			createRegistryValue HKEY_CURRENT_USER,strUserData & "Excel","UserData",1
			
		End If
		
		If objRegistry.EnumKey (HKEY_LOCAL_MACHINE, strUserData & "Common", arrValueNames) = 0 Then
'if reg key exists, write UserData in HKCU
'create registry key
			createRegistryKey HKEY_CURRENT_USER, strUserData & "Common"
'write dword value
			createRegistryValue HKEY_CURRENT_USER,strUserData & "Common","UserData",1
			
'create registry key
			createRegistryKey HKEY_CURRENT_USER, "Software\ODBC\ODBC.INI\MS Access Database"
		End If
		
	Next
	
	Set objRegistry= Nothing
End Function


Function createRegistryKey(hive, path)
	
	If objRegistry.EnumKey (hive, path, arrValueNames) <> 0 Then
'reg key does not exist
		return = objRegistry.CreateKey(hive, path)
		If (return = 0) And (Err.Number = 0) Then
			writeToLog("Created 'HKCU\" & path & "' registry key...")
			keyArray(keyCount) = path
			keyCount = keyCount + 1
		Else
			writeToLog("Error creating 'HKCU\" & path & "' registry key...")
			On Error GoTo 0
		End If
	End If
End Function

Function deleteRegistryKey(hive, path)
	
	If objRegistry.EnumKey (hive, path, arrValueNames) = 0 Then
'reg key exists
		return = objRegistry.DeleteKey(hive, path)
		If (return = 0) And (Err.Number = 0) Then
			writeToLog("Deleted 'HKCU\" & path & "' registry key...")
		Else
			writeToLog("Error deleting 'HKCU\" & path & "' registry key...")
			On Error GoTo 0
		End If
	End If
	
End Function

Function createRegistryValue(hive, path, valuename, valuedata)
	
	objRegistry.GetDWORDValue hive,path,valuename,valuedata
	
	If IsNull(valuedata) Then
		return = objRegistry.SetDWORDValue(hive,path,valuename,valuedata)
		If (return = 0) And (Err.Number = 0) Then
			writeToLog("Created 'HKCU\" & path & "\" & valuename & "' value...")
			
			valueArray(valueCount,0) = path
			valueArray(valueCount,1) = valuename
			valueCount = valueCount + 1
		Else
			writeToLog("Error creating 'HKCU\" & path & "\" & valuename & "' value...")
			On Error GoTo 0
		End If
	End If
End Function

Function deleteRegistryValue(hive, path, valuename)
	
	objRegistry.GetDWORDValue hive,path,valuename,valuedata
	
	If Not IsNull(valuedata) Then
		return = objRegistry.DeleteValue(hive,path,valuename)
		If (return = 0) And (Err.Number = 0) Then
			writeToLog("Deleted 'HKCU\" & path & "\" & valuename & "' value...")
		Else
			writeToLog("Error deleting 'HKCU\" & path & "\" & valuename & "' value...")
			On Error GoTo 0
		End If
	End If
End Function

'*******************************************
'This function installs the Excel Addin
'*******************************************
Function AddExcelAddin(ByVal strAddIn)
	Dim objFSO_XL
	Dim intCounter : intCounter = 0
	Dim blnInstalledAlready : blnInstalledAlready = False
	Dim addinName : addinName = Right(strAddIn,Len(strAddIn)-InStrRev(strAddIn,"\"))
	AddExcelAddin = False
	
	Set objFSO_XL = CreateObject("Scripting.FileSystemObject")
	With objFSO_XL
		strMsg = ""
		On Error Resume Next
'Check source file exists
		If Not .FileExists(strAddIn) Then
			strMsg = "The source file " & strAddIn & " does not exist." & VbCrLf & "'" & strAddIn & "' was not installed."
			writeToLog(strMsg)
			Exit Function
		End If
		On Error GoTo 0
	End With
	
	On Error Resume Next
'create Excel object
	Set objXL = CreateObject("Excel.Application")
	If Err.Number <> 0 Then
		strMsg = "Failed to create Excel object." & VbCrLf
		strMsg = strMsg & "'" & strAddIn & "' was not installed."
		
		writeToLog(strMsg)
		On Error GoTo 0
	Else
		strMsg = "Created Excel object."
		writeToLog(strMsg)
	End If
'add workbook
	Set objWorksheet = objXL.Workbooks.Add()
	If Err.Number <> 0 Then
		strMsg = "Failed to create new workbook." & VbCrLf
		strMsg = strMsg & "'" & strAddIn & "' was not installed."
		
		writeToLog(strMsg)
		On Error GoTo 0
	Else
		strMsg = "Created worksheet object."
		writeToLog(strMsg)
	End If
	
'try and add addin
	With objXL
		For intCounter = 1 to .Addins.Count
			If LCase(.Addins(intCounter).Name) = LCase(addinName) Then
				If .Addins.Item(intCounter).Installed Then
					blnInstalledAlready = True
					AddExcelAddin = True
					Exit For
				End If
			End If
		Next
		
		If Not blnInstalledAlready Then
			Set objAddin = .AddIns.Add(strAddIn)
			If Err.Number <> 0 Then
				strMsg = ""
				strMsg = strMsg & "Error: " & Err.Description & vbCRLF
				strMsg = strMsg & "Failed to add add-in '" & strAddIn & "'." & vbCRLF & "'" & strAddIn & "' was not installed."
				writeToLog(strMsg)
				On Error GoTo 0
			Else
				objAddin.Installed = True
				If Err.Number <> 0 Then
					strMsg = ""
					strMsg = strMsg & "Error: " & Err.Description & vbCRLF
					strMsg = strMsg & "Failed to set add-in installed status." & vbCRLF & "'" & strAddIn & "' was not installed."
					writeToLog(strMsg)
				Else
					strMsg = "Add-in '" & strAddIn & "' installed successfully."
					AddExcelAddin = True
					writeToLog(strMsg)
				End If
			End If
		Else
			strMsg = "Add-in '" & strAddIn & "' is already installed." & vbCRLF & "'" & strAddIn & "' was not installed."
			writeToLog(strMsg)
		End If		
		
	End With
	Set objWorksheet = Nothing
	objXL.Quit
	Set objFSO_XL = Nothing
	Set objAddin = Nothing
	Set objXL = Nothing	
	
End Function

Function revertRegistry()
	
	Set objRegistry= GetObject("winmgmts:\\" & strComputer & "\root\default:StdRegProv")
	
'deleteRegistryKey(hive, path)
	For i = 0 to ubound(keyArray)
		If Not CStr(keyArray(i)) = "" Then
			deleteRegistryKey HKEY_CURRENT_USER, CStr(keyArray(i))
		End If
	Next
	
'deleteRegistryValue(hive, path, valuename)
	For i = 0 to UBound(valueArray)
		If Not CStr(valueArray(i,0)) = "" Then
			deleteRegistryValue HKEY_CURRENT_USER, CStr(valueArray(i,0)), CStr(valueArray(i,1))
		End If
	Next
	
	Set objRegistry= Nothing
	
End Function


Const msiMessageTypeInfo = &H04000000
'Subroutine to write to log file
Sub writeToLog(ByVal msg)
	Set record = Installer.CreateRecord(1)
	record.stringdata(0) = "AddExcelAddinStatus: [1]"
'This value gets subbed in to the [1] placeholder above
	record.stringdata(1) = msg
	record.formattext
	message msiMessageTypeInfo, record
	Set record = Nothing
End Sub

Step 4 – Create Custom Action to Uninstall Add-In

  • Now create another Type 38 CA. Call it ‘removeAddin’.
  • Schedule it straight after InstallInitialize and make it Deferred in a User Context (Setting it as deferred etc makes the Type become 1062 in your CA table).
    Give it a condition of:

REMOVE~=”ALL”

  • Paste the following code into your CA (You should only need to edit the value of blnDeleteFromAllProfiles. LEAVE EVERYTHING ELSE ALONE.):
'*************************************
'logic to uninstall addin (can be used for automation addins or COM addins)
'(All status messages are printed to installer log)
'(All log status entries start with 'RemoveExcelAddinStatus: {status}')
'*************************************

'set this to true/false depending on whether you want to attempt to delete the HKCU\xxxx\OPENx value from each user profile
'true = delete from all profiles false=delete from current profile only
Dim blnDeleteFromAllProfiles : blnDeleteFromAllProfiles = False

Dim blnReturn : blnReturn = False
Dim objXL
Dim objWorksheet
Dim objAddin
Dim strAddIn : strAddIn = ""
Dim strMsg : strMsg = ""
Dim strAddInName : strAddInName = ""
Dim addinList : addinList = ""
Dim addinListArray : addinListArray = ""
Dim i : i = 0
Const HKEY_CURRENT_USER = &H80000001
Const HKEY_LOCAL_MACHINE = &H80000002
Const HKEY_USERS = &H80000003
Dim tempRelativeId : tempRelativeId = 0
Dim strComputer : strComputer = "."
Dim strAddinKeyPath, strAddinValueName
Dim strValueName : strValueName = ""
Dim objRegistry, objFSO, objWshShell


'retrieve the value of the property we set earlier
'(The value is comma-separated in the form 'featureInstallState, Addin1, Addin2, Addin3......' etc)
addinList = Session.Property("CustomActionData")
'write value of Session Proeprty to log for debugging purposes

writeToLog("Deferred property contains: " & addinList)

If Len(addinList) > 0 Then 'if we found an add-In
	
	addinListArray = split(addinList,",")
	
'for every addin passed in our property
	For i = 0 To UBound(addinListArray)
		strAddInName = addinListArray(i)
		
'we're uninstalling
		blnReturn = RemoveExcelAddin(strAddInName)
		
		If Not blnReturn Then
			strMsg = "Unable to uninstall Excel add-in '" & strAddInName & "'"
			writeToLog(strMsg)
		Else
'now it's uninstalled we attempt to remove keys from add-in manager
'we do it here because it only gets generated after uninstall when our reference to Excel.Application is closed
			Set objRegistry= GetObject("winmgmts:\\" & strComputer & "\root\default:StdRegProv")
			Set objFSO = CreateObject("Scripting.FileSystemObject")
			Set objWshShell = CreateObject("WScript.Shell")
			
'delete for current user
			deleteFromProfile HKEY_CURRENT_USER,""
			
			If blnDeleteFromAllProfiles Then
				
'try deleting key from all profiles
'profilelist reg key contains profiles which have logged on to the machine (and some default profiles too)
				Dim strProfileListKeyPath
				strProfileListKeyPath = "SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList"
				objRegistry.EnumKey HKEY_LOCAL_MACHINE, strProfileListKeyPath, arrSubkeys
				
				Dim arrSubkeys, objSubkey, strProfileValueName, strSubPath, ntuserfile, userfolder, officeversion, strOptionsKeyPath
				Dim arrValueNames, arrValueTypes, strOptionsValueName, strValue, a
				
'enumerate all SIDs in profile list (profiles which have logged on to machine)
				For Each objSubkey In arrSubkeys
					tempRelativeId = Split(objSubkey,"-")
					
'check its not one of the default SIDs
					If nonDefaultRelativeId(tempRelativeId(UBound(tempRelativeId))) Then
						
						strProfileValueName = "ProfileImagePath"
						strSubPath = strProfileListKeyPath & "\" & objSubkey
						objRegistry.GetExpandedStringValue HKEY_LOCAL_MACHINE,strSubPath,strProfileValueName,userfolder
						ntuserfile = userfolder & "\ntuser.dat"
						
'check the ntuser.dat file exists before we temporarily import it
						If objFSO.fileExists(ntuserfile) Then
							deleteFromProfile HKEY_USERS,ntuserfile
						End If
					End If
				Next
				
				Set objRegistry = Nothing
				Set objFSO = Nothing
				Set objWshShell = Nothing
				
			End If
			
		End If
	Next
Else
	strMsg = "No add-ins were found. If you are installing add-ins from a specific directory, check the case of your specified directory in the setAddinProperty CA."
	writeToLog(strMsg)
End If



'*******************************************
'this function unloads and then deletes the add-in from the add-in manager.
'*******************************************

Function deleteFromProfile(HIVEKEY,ntuserfile)
	
	On Error Resume Next
	
	If Not ntuserfile = "" Then
		objWshShell.Run "Reg.exe load HKEY_USERS\tempRegistry " & chr(34) & ntuserfile & chr(34), 0, True
		strMsg = "Attempting to remove Add-in for ntuser file: " & ntuserfile
		writeToLog(strMsg)
	Else
		strMsg = "Attempting to remove Add-in for current user"
		writeToLog(strMsg)
	End If
	
'unload and delete from add-in list for Office 97 to 2010
	For officeversion = 8 to 14
		strOpenKeyPath = "Software\Microsoft\Office\" & officeversion & ".0\Excel\Options"
		strAddinKeyPath = "Software\Microsoft\Office\" & officeversion & ".0\Excel\Add-in Manager"
		
		If Not ntuserfile = "" Then
			strOpenKeyPath = "tempRegistry\" & strOpenKeyPath
			strAddinKeyPath = "tempRegistry\" & strAddinKeyPath
		End If
		
'unload from addin manager (delete OPENx value)
		
		objRegistry.EnumValues HIVEKEY, strOpenKeyPath, arrValueNames, arrValueTypes
'check if a value is returned
		If IsArray(arrValueNames) Then
'if so, loop through values in the registry key
			For a=0 To UBound(arrValueNames)
				strOpenValueName = arrValueNames(a)
'if the value starts with 'OPEN', then its an addin
				If Left(UCase(strOpenValueName),4) = "OPEN" Then
					objRegistry.GetStringValue HIVEKEY,strOpenKeyPath,strOpenValueName,strValue
'we check the OPEN value to see if it's our addin that we need to remove
					If InStr(1,strValue,strAddInName,1) > 0 Then
						strMsg = "Unloading: " & Replace(strOpenKeyPath,"tempRegistry\","") & "\" & strOpenValueName
						writeToLog(strMsg)
'If it is, we delete it
						objRegistry.DeleteValue HIVEKEY,strOpenKeyPath,strOpenValueName
						
						If Err.Number <> 0 Then
							strMsg = "Unloaded: " & strOpenKeyPath & "\" & strOpenValueName
							writeToLog(strMsg)
						Else
							strMsg = "Could not unload: " & strOpenKeyPath & "\" & strOpenValueName
							writeToLog(strMsg)
'reset error handling
							On Error GoTo 0
						End If
						
					End If
				End If
			Next
		End If
		
'delete from addin manager
		
		objRegistry.EnumValues HIVEKEY, strAddinKeyPath, arrValueNames, arrValueTypes
'check if a value is returned
		
		If isArray(arrValueNames) Then
'if so, loop through values in the registry key
			For a=0 To UBound(arrValueNames)
				strAddinValueName = arrValueNames(a)
'if the value name is the same as our addin
				If InStr(1,strAddinValueName,strAddInName,1) > 0 Then
					strMsg = "Deleting: " & Replace(strAddinKeyPath,"tempRegistry\","") & "\" & strAddinValueName
					writeToLog(strMsg)
'If its the addin, we delete it
					objRegistry.DeleteValue HIVEKEY,strAddinKeyPath,strAddinValueName
					
					If Err.Number <> 0 Then
						strMsg = "Deleted: " & strAddinKeyPath & "\" & strAddinValueName
						writeToLog(strMsg)
					Else
						strMsg = "Could not delete: " & strAddinKeyPath & "\" & strAddinValueName
						writeToLog(strMsg)
'reset error handling
						On Error GoTo 0
					End If
					
				End If
			Next
		End If
	Next
	
	If Not ntuserfile = "" Then
		objWshShell.Run "Reg.exe unload HKEY_USERS\tempRegistry", 0, True
	End If
	
'reset error handling
	On Error GoTo 0
	
End Function



'*******************************************
'Any group or user that is not created by default will have a Relative ID of 1000 or greater.
'The last hyphen-separated value in a SID is the relative id. This function omits these accordingly
'*******************************************


Function nonDefaultRelativeId(relativeId)
	
	nonDefaultRelativeId = False
	
	If IsNumeric(relativeId) Then
		If relativeId >= 1000 Then
			nonDefaultRelativeId = True
		End If
	End If
	
End Function


'*******************************************
'This function removes the Excel Addin
'*******************************************

Function RemoveExcelAddin(ByVal strAddIn)
	
	Dim intCounter : intCounter = 0
	Dim blnInstalled : blnInstalled = False
	Dim addinName : addinName = Right(strAddIn,Len(strAddIn)-InStrRev(strAddIn,"\"))
	RemoveExcelAddin = False
	
	
	On Error Resume Next
	Set objXL = CreateObject("Excel.Application")
	If Err.Number <> 0 Then
		strMsg = "Failed to create Excel object." & VbCrLf
		strMsg = strMsg & "'" & strAddIn & "' was not installed."
		writeToLog(strMsg)
	Else
		strMsg = "Created Excel object."
		writeToLog(strMsg)
	End If
	
'reset error handling
	On Error GoTo 0
	
	With objXL
		For intCounter = 1 To .Addins.Count
			If LCase(.Addins(intCounter).Name) = LCase(addinName) Then
				If .Addins.Item(intCounter).Installed Then
					blnInstalled = True
					Exit For
				End If
			End If
		Next
		
		If blnInstalled Then
'intCounter ought still to be at the correct position,
'since we exited the For...Next loop when we located the add-in
			.Addins.Item(intCounter).Installed = False
			If Err.Number <> 0 Then
				strMsg = ""
				strMsg = strMsg & "Error: " & Err.Description & vbCRLF
				strMsg = strMsg & "Failed to remove add-in '" & strAddIn & "'." & vbCRLF & "'" & strAddIn & "' was not removed."
				
				writeToLog(strMsg)
				
'reset error handling
				On Error GoTo 0
			Else
				strMsg = "Add-in '" & strAddIn & "' removed successfully."
				blnInstalled = False
				RemoveExcelAddin = True
				writeToLog(strMsg)
			End If
		Else
			strMsg = "Add-in '" & strAddIn & "' is not installed, so no removal necessary." & vbCRLF & "'" & strAddIn & "' was not removed."
			
			writeToLog(strMsg)
'we return true so that the relevant OPENx keys are removed
			RemoveExcelAddin = True
		End If
		
	End With
	
	objXL.Quit
	Set objAddin = Nothing
	Set objXL = Nothing
	
End Function


Const msiMessageTypeInfo = &H04000000

'Subroutine to write to log file
Sub writeToLog(ByVal msg)
	Set record = Installer.CreateRecord(1)
	record.stringdata(0) = "RemoveExcelAddinStatus: [1]"
'This value gets subbed in to the [1] placeholder above
	record.stringdata(1) = msg
	record.formattext
	message msiMessageTypeInfo, record
	Set record = Nothing
End Sub

Step 5 – Check Load Behaviour of Add-In

For COM add-ins, you’ll find that it writes a key similar to this:

HKCU\Software\Microsoft\Office\Excel\Addins\[Addin]

Under this key will be a value called ‘LoadBehaviour’ and it will most probably have a value of 3 (Load automatically).

This can cause problems when using automation to install/load a COM Addin, so I recommend setting this to ‘0’ in your installer (Unloaded/Don’t load automatically)

Packaging a PDF Printer Driver

I’ve been working on a PDF printer driver recently.  It was installed as part of another product – a crumby old InstallShield setup.exe that didn’t install or uninstall silently (and response files didn’t work).  As such it required repackaging.

I couldn’t locate an INF file for the printer driver, so I had to cobble one together (more on that another time) and sign the driver myself.  Once I did this I then had to install it and remove it cleanly.  To accomplish this, I used the following script inside an elevated Custom Action (Deferred in a System Context) and sequenced just before the InstallFinalize standard action:

Option Explicit 

'if running on x64 OS, PNPUtil.exe only exists in System32 (not SysWOW64) so ensure that this custom action
'runs in x64 mode.  Or change the path to PNPUtil.exe to use SysNative.

'define variables since we use Option Explicit 
dim wshShell : Set wshShell = CreateObject("Wscript.Shell")
dim fso : Set fso = CreateObject("Scripting.FilesystemObject")
dim value, i, publishednameval, driverpackageproviderval, classval, driverdateandversionval, signernameval

'********************************
'specify the printer options below
'********************************

'printer name as it will appear in printer queue
dim printerName : printerName = "Black Ice PDF"
'driverName value from INF
dim printerDriverNameInf : printerDriverNameInf = "Black Ice PDF Driver"
'DriverVer value from INF
dim printerDriverVerInf : printerDriverVerInf = "06/19/2019,15.25.0.0"
'Driver Manufacturer value from INF
dim printerDriverManufacturerInf : printerDriverManufacturerInf = "Black Ice Software LLC"
'Port name
dim portName : portName = "IcePortPUR:"
'Path to INF file
dim pathToInf : pathToInf = Session.Property("CustomActionData") & "\BlackIcePDFDesktop.inf"

'********************************
'specify whether you are adding or removing the printer
'********************************

'if we want to add the printer, uncomment add_printer and comment remove_printer.
add_printer printerName,printerDriverNameInf,pathToInf,portName

'if we want to remove the printer, uncomment remove_printer and comment add_printer
'remove_printer printerName, printerDriverNameInf,printerDriverVerInf,printerDriverManufacturerInf

'********************************
'do not edit below here
'********************************

'This function splits each line of the PNPUtil.exe output.
'We're specifically trying to locate the name of the OEMxx.INF file
function get_pnputil_value(pnputilentry)

	value = ""
	if instr(pnputilentry,":") > 0 then
		value = split(pnputilentry,":")(1)
	end if
	value = trim(value)
	get_pnputil_value = value
	
end function

'removes printer, printer driver and removes it from Driver Store
function remove_printer(printerName, driverName, driverVersion, drivermanufacturer)
	
	'replace comma in INF driver version
	driverVersion = replace(driverVersion,","," ")

	'remove printer from queue - /q will hide any prompts so remove whilst debugging
	wshShell.Run "RUNDLL32 printui.dll, PrintUIEntry /dl /n " & chr(34) & printerName & chr(34) & " /q", 0, true

	'remove driver - /q will hide any prompts so remove whilst debugging
	wshShell.Run "RUNDLL32 printui.dll, PrintUIEntry /dd /m " & chr(34) & driverName & chr(34) & " /q", 0, true

	'now remove from driver store
	dim strCmd : strCmd = "cmd /q /c for /f ""skip=2 tokens=*"" %a in ('pnputil.exe -e') do @echo %a"
	dim drivers : drivers = wshShell.Exec(strCmd).StdOut.ReadAll()

	'split output by new line
	dim alldrivers : alldrivers = split(drivers,VbCrlf)

    'loop over each line in PNPUtil.exe output
	for i = 0 to Ubound(alldrivers) -1 Step 5
		publishednameval = get_pnputil_value(alldrivers(i))
		driverpackageproviderval = get_pnputil_value(alldrivers(i+1))
		classval = get_pnputil_value(alldrivers(i+2))
		driverdateandversionval = get_pnputil_value(alldrivers(i+3))
		signernameval = get_pnputil_value(alldrivers(i+4))

        'if manufacturer and version matches ours, remove from Driver Store
		if (driverpackageproviderval = drivermanufacturer and driverdateandversionval = driverVersion) then
			wshShell.Run "pnputil.exe -f -d " & publishednameval, 0, true
		end if
	next

	'stop spooler
	wshShell.Run "NET STOP Spooler", 0, true

	'start spooler
	wshShell.Run "NET START Spooler", 0, true

end function

function add_printer(printerName, driverName, pathToInf, portName)

	if fso.FileExists(pathToInf) Then
	
        'Either create a port (if required) here using printui.dll, or (in our case) the port
        'is created via the windows registry and related files.
	
		'stopping and starting spooler first will ensure that our new port is available

		'stop spooler
		wshShell.Run "NET STOP Spooler", 0, true

		'start spooler
		wshShell.Run "NET START Spooler", 0, true

		'add printer - /q will hide any prompts so remove whilst debugging.  /y to set it as default.
		wshShell.Run "RUNDLL32 printui.dll,PrintUIEntry /if /b " & chr(34) & printerName & chr(34) & " /f " & chr(34) & pathToInf & chr(34) & " /r " & chr(34) & portName & chr(34) & " /m " & chr(34) & driverName & chr(34) & " /q", 0, true
	end if
	
end function

Set wshShell = Nothing
Set fso = Nothing

Configuring the Script

  1. This script is used to install AND remove printers, by commenting out the appropriate line (see script comments).
  2. To get the pathToInf value I use a ‘Set Property’ custom action to pass the location of the INF file to my deferred custom action.  So in essence I ended up with 2 VBScript custom actions (for install and remove) and 2 Set Property custom actions (for install and remove).  If you wanted to be lazy you could always hard code the path.
  3. On x64 platforms, you MUST set the msidbCustomActionType64BitScript flag on the Custom Action because PNPUtil.exe only exists in the System32 folder and NOT the SysWOW64 folder.

Creating the Printer Port

There are a few ways to create a printer port, depending upon your requirements.  You could create an entry in HKLM\Software\Microsoft\Windows NT\CurrentVersion\Ports and stop/start the print spooler.  You could also use PrintUI.dll to create a printer port.

In this example, the printer port is created by a combination of registry and files put down by the Windows Installer:

[HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Print\Monitors\Ice Monitor P]
"Driver"="BuPMonNT.dll"

[HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Print\Monitors\Ice Monitor P\PortList]
"IcePortPUR:"=" "

We can see that the driver DLL is called BuPMonNT.dll, which resides in the System32 folder.  Not only does this file need to exist for the port creation to work, but the dependencies of this DLL need to exist too.  Using Dependency Walker to view BuPMonNT.dll we can see that one dependency was missing (PDF32.DLL – there were several other dependencies required too).  Once we added this to System32, added the aforementioned registry and stopped and started the printer spooler, the printer port registered successfully during the script execution.

printer-driver-dependencies

InstallShield Could Not Find File – Error Code -6103

My current client uses the standalone InstallShield repackager to snapshot applications on virtual machines.  When we copy the captured project back to the host machine and try to build the project, we often get errors such as “Could not find file xxx” (Error code -6103)

This issue is present because in the ISPathVariable table, the SOURCEFILEPATH entry will contain a hard coded path – usually C:\Packages.  What we need to do is change this to a relative path so that we can build our package successfully from any location.  So, considering a project folder structure similar to the below:

 

 

 

 

 

We can replace the SOURCEFILEPATH value with <ISProjectFolder>..\ like so:

 

 

<ISProjectFolder> is merely a reference to the project folder location (the folder that contains your .ISM file).  By adding a couple of periods and a backslash to the end, we are telling InstallShield to look up one level from our project folder (i.e, look in the Software folder), and that will contain the source files for our package!

Clean Up the Windows Start Menu

Sometimes after an install or an uninstall we want to clean up the start menu – usually this involves removing shortcuts to uninstall the application, shortcuts that link to help files and/or shortcuts that attempt to install additional components.  This tidy script (tested on Windows 7) can be used to populate file(s) and folder(s) names that require deleting.

Option Explicit

dim objFSO : Set objFSO = CreateObject("Scripting.FileSystemObject") 
dim objShell : Set objShell = CreateObject("WScript.Shell") 

dim strProgramsPath : strProgramsPath = objShell.ExpandEnvironmentStrings("%AllUsersProfile%") & "\Microsoft\Windows\Start Menu\Programs"

'delete an array of files (shortcuts) from the start menu
dim arrFilesToDelete : arrFilesToDelete = Array( _
	strProgramsPath & "\Folder1\Link1.lnk", _
	strProgramsPath & "\Folder1\Link2.lnk", _
	strProgramsPath & "\Folder2\Link3.lnk", _
	strProgramsPath & "\Folder2\Link4.lnk" _
	)

dim strFile
For Each strFile In arrFilesToDelete
	If objFSO.FileExists(strFile) Then objFSO.DeleteFile strFile, True
Next

'delete an array of folders from the Start Menu
dim arrFoldersToDelete : arrFoldersToDelete = Array( _
	strProgramsPath & "\Folder1", _
	strProgramsPath & "\Folder2" _
	)

dim strFolder
For Each strFolder In arrFoldersToDelete
	If objFSO.FolderExists(strFolder) Then objFSO.DeleteFolder strFolder, True
Next

Set objFSO = Nothing
Set objShell = Nothing

 

A Guide to Signing Un-signed Drivers (Part 3)

This post continues from part 1 of the driver signing series found here and part 2 of the series found here.

Part 3 of our driver signing guide deviates slightly from the self-signed certificate approach. My current client uses Active Directory Certificate Services (ADCS) to create and issue certificates. I won’t discuss ADCS in any detail because I’m not a security expert. However, there’s a useful guide here that will assist you in creating a code signing certificate for use with all your unsigned device drivers.

I exported our code signing certificate from ADCS as a pfx file, which importantly, forms part of a certificate chain with the root certificate (our code signing certificate is signed by the root certificate). The root certificate for the organisation is already in the Trusted Root store (for the computer….not the user) on all machines. If this root certificate didn’t exist, our new crtificate chain will not validate and hence our ‘signed’ driver would not appear as being signed when we install it.

The exported code signing certificate uses SHA-256 encryption.  Exporting certificates as SHA-256 is currently a security best practise. As a result of this, when we’re signing a driver’s .cat file the syntax is slightly different (ensure you download the latest version of signtool.exe before running this command line):

signtool.exe sign /fd SHA256 /f C:\driver\AlkaneCert.pfx /p PASSWORD /tr http://sha256timestamp.ws.symantec.com/sha256/timestamp C:\driver\Alkane.cat

Now all you need to do is ensure that your code signing certificate is imported into the Trusted Publisher store  (for the computer….not the user) on every machine in your organisation.  But wait….

Even though we did this, when we installed the driver it appeared signed (yay) but we received a message saying the publisher wasn’t trusted (boo)!  After much debugging, it turned out that this patch (KB2921916) released from Microsoft was required on all machines and fixed the issue!

Before I wrap up, I should also mention a very nice article I found here explaining the driver signing process in more detail.

A Guide to Signing Un-signed Drivers (Part 2)

This post continues from part 1 of the driver signing series which can be found here.  Part 3 of the series can be found here.

It’s been a while since I looked at signing un-signed drivers I must admit. In fact, I’ve probably not signed one since my original post on AppDeploy (now ITNinja) back in 2010!  I’ve been caught up in ‘other worlds’ since then, but I’m finally back to write a second part to this original post (which incidentally I’ve modified and re-written here and you may need to reference this throughout this post).

Anyway, this second part is a small piece on how driving signing works (high level) and an issue that I’ve stumbled upon and resolved recently.

Why Sign a Driver?

It’s a way of ensuring that a driver package has not been tampered with since it was tested and approved.  In our self-signing example, you are testing and approving it!  It also means that non-administrative users will not get any security prompts when installing drivers for Plug and Play devices (depending upon your driver signing policy).

What is a Catalog file used for?

A catalog file contains digital signatures that reference each file in your driver’s INF file (referenced by the CopyFiles directive…..read on), allowing the operating system to verify the integrity of every file.  If any files referenced by the driver INF file are changed after digitally signing the catalog file (including the INF itself!), the signature will be broken.

How is the Catalog Used when we Install the Driver?

The inf file looks for the catalog file that it is referencing and verifies the digital signature.  It also verifies the cryptographic checksum of every file that is recorded in the signed catalog file.  If any of this does not comply, the driver install will fail.

Debugging a Driver Install (With an Example)

I’m working on an un-signed Smart Card printer by IDP called Smart 50s.  Here is the original INF file content:

; SMART.INF
;
; INF file for SMART Card Printer
;
; Copyright 2013 IDP Corp., Ltd



[Version]
Signature="$Windows NT$"
Provider=%Provider%
ClassGUID={4D36E979-E325-11CE-BFC1-08002BE10318}
Class=Printer
CatalogFile=smart.cat
DriverVer=09/16/2014,2.14.09.16

;
; Manufacturer section.
;
; This section lists all of the manufacturers 
; that we will display in the Dialog box
;
[Manufacturer]
%Manufacturer%=INA, NTamd64, NTia64

;
; Model sections. 
; 
; Each section here corresponds with an entry listed in the
; [Manufacturer] section, above. The models will be displayed in the order
; that they appear in the INF file.
;

[INA]
%Product%=Product,USBPRINT\INASystemSMART_PrintDEAE
%Product30%=Product,USBPRINT\IDPSMART-30_Printer6622

[INA.NTamd64]
%Product%=Product,USBPRINT\INASystemSMART_PrintDEAE
%Product30%=Product,USBPRINT\IDPSMART-30_Printer6622

[INA.NTia64]
%Product%=Product,USBPRINT\INASystemSMART_PrintDEAE
%Product30%=Product,USBPRINT\IDPSMART-30_Printer6622
;
; Installer Sections
;
; These sections control file installation, and reference all files that
; need to be copied. The section name will be assumed to be the driver
; file, unless there is an explicit DriverFile section listed.
;



[Product]
CopyFiles=@smart.GPD
CopyFiles=@smart.INI
CopyFiles=@STDNAMES.GPD
CopyFiles=PRINTERUI
CopyFiles=PRINTERUNI
CopyFiles=PRINTERICM
CopyFiles=PRINTERMON
CopyFiles=PRINTERLIB
DataFile=smart.GPD
DataSection=UNIDRV_DATA
LanguageMonitor="IDP SMART Language Monitor,smartmon.dll"
Include=NTPRINT.INF
Needs=UNIDRV.OEM,UNIDRV_DATA



; Copy Sections
;
; Lists of files that are actually copied. These sections are referenced
; from the installer sections, above. Only create a section if it contains
; two or more files (if we only copy a single file, identify it in the
; installer section, using the @filename notation) or if it's a color
; profile (since the DestinationDirs can only handle sections, and not
; individual files).
;

[PRINTERUI]
smartUI.DLL

[PRINTERUNI]
smartUNI.DLL

[PRINTERICM]
cardprinter_d.icm
cardprinter_k.icm
cardprinter_k2.icm
cardprinter_k3.icm

[PRINTERLIB]
SmartComm2.dll
watch.dll
DrvPlugin.exe
CleanPrinter.exe
Watchman.exe
cputil.exe
cardprinter_mask_0.bmp
cardprinter_mask_1.bmp
cardprinter_mask_2.bmp
cardprinter_mask_3.bmp
cardprinter_mask_4.bmp
cardprinter_mask_5.bmp
cardprinter_mask_6.bmp
cardprinter_smart_p_0.prn
cardprinter_smart_p_1.prn
cardprinter_smart_p_2.prn
cardprinter_smart_p_3.prn
cardprinter_smart_p_4.prn
cardprinter_smart_p_5.prn
cardprinter_smart_p_6.prn
cardprinter_smart_p_7.prn
cardprinter_smart_p_8.prn
cardprinter_smart_p_9.prn
cardprinter_smart_s_0.prn
cardprinter_smart_s_1.prn
cardprinter_smart_s_2.prn
cardprinter_smart_s_3.prn
cardprinter_smart_s_4.prn
cardprinter_smart_s_5.prn
cardprinter_smart_s_6.prn
cardprinter_smart_s_7.prn
cardprinter_smart_s_8.prn
cardprinter_smart_s_9.prn
cardprinter_encoding.ini


[PRINTERMON]
smartmon.dll,,,0x00000020


;
; Data Sections
;

; These sections contain data that is shared between devices.
;



;
;  Location of source files not in Layout.inf.
;

[SourceDisksNames]
100 = %Product%

[SourceDisksFiles]
smart.GPD			= 100
smart.INI			= 100
STDNAMES.GPD			= 100
cardprinter_d.icm		= 100
cardprinter_k.icm		= 100
cardprinter_k2.icm		= 100
cardprinter_k3.icm		= 100
SmartComm2.dll			= 100
watch.dll			= 100
DrvPlugin.exe			= 100
CleanPrinter.exe		= 100
Watchman.exe			= 100
cputil.exe			= 100
cardprinter_mask_0.bmp		= 100
cardprinter_mask_1.bmp		= 100
cardprinter_mask_2.bmp		= 100
cardprinter_mask_3.bmp		= 100
cardprinter_mask_4.bmp		= 100
cardprinter_mask_5.bmp		= 100
cardprinter_mask_6.bmp		= 100
cardprinter_smart_p_0.prn	= 100
cardprinter_smart_p_1.prn	= 100
cardprinter_smart_p_2.prn	= 100
cardprinter_smart_p_3.prn	= 100
cardprinter_smart_p_4.prn	= 100
cardprinter_smart_p_5.prn	= 100
cardprinter_smart_p_6.prn	= 100
cardprinter_smart_p_7.prn	= 100
cardprinter_smart_p_8.prn	= 100
cardprinter_smart_p_9.prn	= 100
cardprinter_smart_s_0.prn	= 100
cardprinter_smart_s_1.prn	= 100
cardprinter_smart_s_2.prn	= 100
cardprinter_smart_s_3.prn	= 100
cardprinter_smart_s_4.prn	= 100
cardprinter_smart_s_5.prn	= 100
cardprinter_smart_s_6.prn	= 100
cardprinter_smart_s_7.prn	= 100
cardprinter_smart_s_8.prn	= 100
cardprinter_smart_s_9.prn	= 100
cardprinter_encoding.ini	= 100

[SourceDisksFiles.x86]
smartui.dll			= 100,i386
smartuni.dll			= 100,i386
smartmon.dll			= 100,i386

[SourceDisksFiles.amd64]
smartui.dll			= 100,amd64
smartuni.dll			= 100,amd64
smartmon.dll			= 100,amd64

[SourceDisksFiles.ia64]
smartui.dll			= 100,amd64
smartuni.dll			= 100,amd64
smartmon.dll			= 100,amd64

;
; Call SetupSetDirectoryId with 66000 to set the target directory at runtime
; (depending on which environment drivers are getting installed)
;

[DestinationDirs]
DefaultDestDir=66000
PRINTERICM=66003
PRINTERMON=66002
PRINTERLIB=11


[Strings]
Provider = "IDP Corp,.Ltd."
Manufacturer = "IDP Corp,.Ltd."
Product = "IDP SMART-50 Card Printer"
Product30 = "IDP SMART-30 Card Printer"

Since we’ve already generated a corporate certificate using my guide, all we really need to do here is sign the catalog file.  Occasionally we need to delete the vendor’s cat file and generate our own cat file.  I sometimes do this for peace of mind, to ensure that the cat file is up to date and contains all the correct file checksums referenced by the INF file.

So next I imported my certificate into the Trusted Root store (i need to do this before I can verify the digital signature), and verified the digital signature of my catalog file using SignTool.exe.  Everything reported a success.

Next (and as a quick test) I lumped a copy of DPInst.exe into the driver folder on my dev machine, and ran it interactively by double clicking it.  And then I gasped:

Unsigned driverHow can this be?  Signtool.exe verified the digital signature successfully?  So I delved into the driver log file which is located here:

C:\Windows\inf\setupapi.dev.log

And when I scrolled down to the bottom (incidentally deleting this file will regenerate a new one, which may be handy when debugging) I found this line:

!!!  sto:                Failed to verify file 'UNIDRVUI.DLL' against catalog. Catalog = smart.cat, Error = 0xE000024B
!!!  sto:                Catalog did not contain file hash. File is likely corrupt or a victim of tampering.
!!!  sto:           Driver package appears to be tampered. Filename = C:\windows\System32\DriverStore\Temp\{36d82f49-0898-19c5-54bf-8b142c4bcd4b}\smart.inf, Error = 0x800F024B
!!!  sto:           Driver package appears to be tampered and user does not want to install it.
!!!  ndv:           Driver package failed signature validation. Error = 0xE000024B

This was interesting.  Take note of the exclamation marks at the very start – a single exclamation is a warning, and a triple exclamation mark is an error/failure   So I did a little Googling and stumbled upon this.  Specifically, it says:

PnP device installation considers the digital signature of a driver package to be invalid if any file in the driver package is altered after the driver package was signed. Such files include the INF file, the catalog file, and all files that are copied by INF CopyFiles directives.

I knew the INF and Catalog files hadn’t been modified since signing them, so it led me to the bit in bold.   Was UNIDRVUI.DLL being copied to the driver store by a CopyFiles directive?  It turns out it wasn’t!!  So this meant fixing a vendor’s INF file which I can assure you, isn’t much fun!  What’s important to note here is that even though Signtool.exe verified the digital signature of my catalog file, that does not mean to say that the catalog file contains all the required file checksums!

Here are the changes I made – unfortunately I won’t go in to how we modify INF files since it’s not a trivial task to explain.  Anyway:
1. Added a CopyFiles directive, pointing to a section called UNIFILES.INF difference 12. Created the UNIFILES section, and added all the files that we missing (it wasn’t just UNIDRVUI.DLL)INF difference 2

3. Added the files to the SourceDisksFiles.x86 section, so that they could be located on disk (they were in a sub folder called i386)
INF difference 3

I then deleted the cat file so I could re-generate it with all the new file checksums.  And I re-signed it with my certificate.  I then ran DPInst.exe and successfully got the following prompt!

Signed successfullyAt this point I thought it was good to go.  As you all probably know, Windows 7 and above come with some handy VBScript printing utilities located here:

C:\Windows\System32\Printing_Admin_Scripts\en-US

The scripts basically use WMI classes under the hood to add/configure printers and ports etc.  I wanted to run  quick test using the WMI approach instead of the PrintUI.dll approach since we can return exit codes, and I think there are issues returning exit codes via Rundll32 and PrintUI.dll (it tends to just print the error out to a dialog window as opposed to returning an exit code).

When we tried to install our driver, it returned an 2148467266 exit code.  So I checked inside setupapi.dev.log and found the following:

!!!  sto:           Driver package signer is unknown. Assuming untrusted signer. Error = 0x800F0242
!!!  ndv:           Driver package failed signature validation. Error = 0xE0000242

Assuming an untrusted signer?  That’s strange.  So I MOVED the certificate to the Trusted Publishers store.  And then I got this:

!!!  sto:           Catalog signer is untrusted. No error message will be displayed as client is running in non-interactive mode.
!!!  ndv:           Driver package failed signature validation. Error = 0xE0000247

Fair enough.  I kind of expected that.  So i decided to put my certificate in BOTH the Trusted Publisher AND the Trusted Root stores.  And voila.  No error messages and a return code of 0!

I’ll probably end up using C:\Windows\System32\PnPutil.exe to install my driver above.  It has a handy command line switch (/i) that installs the driver on matching devices that are connected to the system (the WMI approach doesn’t seem to do this – although perhaps a little more research is needed surrounding this!).  In other words, when a user plugs in this device (without the drivers installed – they need to plug it in first to mount the Virtual Printer port) it will create an entry under Devices and Printers in the ‘Unspecified’ section.  Installing the driver and adding a printer via WMI works, but also leaves the ‘Unspecified’ device until the user logs off and logs back in (even after a print spooler stop and start).  PnPutil.exe seems to install the driver and then match it up to this unspecified device and moves it to the Printers and Faxes section.  That’s all….for now.

Create a Symbolic Link from a Custom Action

Creating a symbolic link can be achieved using the MKLINK internal command.  Internal commands are native to the windows command shell and are NOT executable files.  As such, we must call these commands via the windows command shell as follows:

Let’s pretend we have a physical folder in C:\Program Files\AlkaneSolutions but we want to create a symbolic link to it in the root of C:\ called ‘AlkaneSolutions’.  I would use the following MKLINK command:

MKlink /D C:\AlkaneSolutions "C:\Program Files\AlkaneSolutions"

From a custom action we should do 2 things:

1.  We cannot call MKLINK directly since it’s an internal command and not an executable.  So instead we call CMD.exe with the /c parameter (/c runs the command and then terminates the command prompt) and then pass the MKLINK command afterwards.

2.  We should not include any hardcoded paths, and instead try to use windows installer properties where possible.  Remembering to enclose any paths in quotes!

As such, and when scheduling the Custom Action to run in a deferred context just before InstallFinalize, our Custom Action should look similar to this:

MKLINK commandThe action ‘MKLINK’ is merely the name of our Custom Action.  The Type relates to the type of custom action and its execution options.  The Source of [SystemFolder] relates to the location of CMD.exe.  And the Target is the command we want to run.

 

Create a URL Shortcut using the IniFile table

With a Windows Installer there are a couple of ways we can create a URL shortcut, but I like to create a URL shortcut using the IniFile table.  In this example, we create a file called ‘Alkane App.url’ on the desktop.  It will look similar to this:

Alkane App Shortcut

 

 

 

If you opened the file in Notepad you would see it looks similar to this (depending upon the public properties we pass at install time):

[InternetShortcut]
URL=http://127.0.0.1:80
IconFile=C:\Program Files\Internet Explorer\iexplore.exe
IconIndex=0

And this is what the IniFile table looks like that creates it:

URL Shortcut IniFile table

A few pointers;  In the FileName column we should specify a short name|long name format (remember we’re adding all these entries to the same file name so they’re all identical).  The DirProperty does NOT require the square brackets (Example, [DesktopFolder] would be incorrect).  The [IPADDRESS] and [PORT] are public properties, which we specify on the install command line.  For example:

MSIEXEC /I AlkaneApp.msi IPADDRESS=”127.0.0.1″ PORT=”80″ /QB!

And the icon for this shortcut points to the Internet Explorer executable, and uses the embedded icon with an index of 0.

Also you should ensure that the Component_ points to a component which will definitely be installed (and removed).

Access a Windows Installer property in a Deferred Custom Action

Description:

This post describes how to access a Windows Installer property in a Deferred Custom Action.  Deferred, commit, and rollback custom actions can only access a limited number of built-in Windows Installer properties – CustomActionData, ProductCode, and UserSID.  In brief (since this has been discussed plenty of times before elsewhere) this is due to them being executed in a separate process (they spawn another MSIEXEC.exe process which is run in a System Account context – check Task Manager during an installation to see this).  To pass any Windows Installer property to a deferred Custom Action, we must pass it via the CustomActionData property.  In this example, we’ll pass the ProductName property during the installation of our product.

Step 1
Create a property. Call it ‘AlkaneCustomProperty’ and give it a default value of anything (we’re going to set this to our directory name in Step 2….).

Step 2
Create a SetProperty custom action (Type 51), call it ‘setAlkaneCustomProperty’, select your ‘AlkaneCustomProperty’ property, and under property value write ‘[ProductName]’. Execute this action as Immediate, before InstallInitialize with a condition of ‘NOT Installed’.

Step 3
Create another CA – this time a ‘Call VBScript from Embedded code’ (Type 38). It is IMPORTANT you call this the same name as your property you made earlier, so call it ‘AlkaneCustomProperty’. In your script, to retrieve the directory name use:

Dim ProductName : ProductName = Session.Property("CustomActionData")
MsgBox ProductName

Schedule this CA as ‘Deferred in a System Context’ and put it anywhere between the standard actions ‘InstallInitialize’ and ‘InstallFinalize’.  Use a condition of ‘NOT Installed’.

Use VBScript to amend the the Hosts/Services or any other text file

Description:

This post shows how to use VBScript to amend the the Hosts/Services or any other text file.  It contains two scripts to add/remove lines of text.  This example adds to the C:\Windows\System32\drivers\etc\Services file.

Source:

N/A

Script:

Add to Services file

dim objFSO : set objFSO = CreateObject("Scripting.FileSystemObject")
dim windowsFolder: windowsFolder = objFSO.GetSpecialFolder(0)
dim targetFile : targetFile = windowsFolder & "\System32\drivers\etc\Services"
Dim arrobjFileLines()
dim i : i = 0
Const ForAppending = 8
Const ForReading = 1

if objFSO.FileExists(targetFile) Then

	Dim objFile : Set objFile = objFSO.OpenTextFile(targetFile, ForReading)
	strobjFile = objFile.ReadAll
	objFile.Close
	Set objFile = Nothing

	Set objFile = objFSO.OpenTextFile(targetFile, ForAppending)
	if right(strobjFile,2) <> VbCrlf then
		objFile.WriteBlankLines 1
	end if

	objFile.WriteLine "alkaneTest1 3601/tcp"
	objFile.WriteLine "alkaneTest2 3602/tcp"
	objFile.WriteLine "alkaneTest3 3603/tcp"
	
	objFile.close
	Set objFile = Nothing
	
end if

Set objFSO = Nothing

Remove from Services file

dim objFSO : set objFSO = CreateObject("Scripting.FileSystemObject")
dim windowsFolder: windowsFolder = objFSO.GetSpecialFolder(0)
dim filename: filename = windowsFolder & "\System32\drivers\etc\Services"
Const ForReading = 1
Const ForWriting = 2

if objFSO.FileExists(filename) Then
	
	Set objFile = objFSO.OpenTextFile(filename, ForReading)
	
	Do Until objFile.AtEndOfStream
	    strLine = objFile.ReadLine
	    If strLine <> "alkaneTest1 3601/tcp" _
		AND strLine <> "alkaneTest2 3602/tcp" _
		AND strLine <> "alkaneTest3 3603/tcp" Then
	        strNewContents = strNewContents & strLine & vbCrLf
		End If		
	Loop
	
	objFile.Close
	Set objFile = Nothing
	
	Set objFile = objFSO.OpenTextFile(filename, ForWriting)
	objFile.Write strNewContents
	objFile.Close
	Set objFile = Nothing
	
end if

Set objFSO = Nothing