Active Setup for installing as User

Thought I’d share a script I’ve developed for installing local user .MSI apps via Active Setup. This is for per-user application installs and not machine-wide.

# Metascript: Stage installer and write Active Setup key for Spoke Phone
Invoke-ImmyCommand {

    # Extract version from MSI file and format for Active Setup (X,X,X,X)
$msiPath = $Using:InstallerFile
$windowsInstaller = New-Object -ComObject WindowsInstaller.Installer
$msiDatabase = $windowsInstaller.OpenDatabase($msiPath, 0)
$view = $msiDatabase.OpenView("SELECT Value FROM Property WHERE Property = 'ProductVersion'")
$view.Execute()
$record = $view.Fetch()
$msiVersion = $record.StringData(1)

# Release COM objects
[System.Runtime.InteropServices.Marshal]::ReleaseComObject($view) | Out-Null
[System.Runtime.InteropServices.Marshal]::ReleaseComObject($msiDatabase) | Out-Null
[System.Runtime.InteropServices.Marshal]::ReleaseComObject($windowsInstaller) | Out-Null

# Convert dot-separated to comma-separated and ensure 4 parts (e.g. "1.2.3" -> "1,2,3,0")
$versionParts = $msiVersion.Split('.')
while ($versionParts.Count -lt 4) { $versionParts += "0" }
$Version = $versionParts[0..3] -join ","

Write-Host "MSI Product Version: $msiVersion -> Active Setup Version: $Version"

    $guid         = "{A1B2C3D4-0000-0000-0000-SPOKE00000001}"
    $registryBase = "HKLM:\SOFTWARE\Microsoft\Active Setup\Installed Components\$guid"
    $stagingDir   = "C:\ProgramData\ImmyBot\SpokePhone"
    $stagedFile   = Join-Path $stagingDir (Split-Path $Using:InstallerFile -Leaf)

    # Stage installer to a user-readable location
    if (-not (Test-Path $stagingDir)) {
        New-Item -Path $stagingDir -ItemType Directory -Force | Out-Null
    }

    Write-Host "Copying installer to staging directory: $stagingDir"
    $robocopy = robocopy (Split-Path $Using:InstallerFile -Parent) $stagingDir (Split-Path $Using:InstallerFile -Leaf) /R:3 /W:5
    
    if ($LASTEXITCODE -ge 8) {
        Write-Host "Robocopy failed with exit code $LASTEXITCODE"
        throw "Failed to stage installer to $stagingDir"
    }

    Write-Host "Installer staged successfully to: $stagedFile"

    # Write Active Setup HKLM key pointing to the staged path
    if (-not (Test-Path $registryBase)) {
        New-Item -Path $registryBase -Force | Out-Null
    }

    Set-ItemProperty -Path $registryBase -Name "(Default)"    -Value "Spoke Phone"
    Set-ItemProperty -Path $registryBase -Name "Version"      -Value $Version -Force
    Set-ItemProperty -Path $registryBase -Name "StubPath" -Value "msiexec.exe /i `"$stagedFile`" /qn /norestart"
    Set-ItemProperty -Path $registryBase -Name "ComponentIDs" -Value "SpokePhone"

    Write-Host "Active Setup key written: $registryBase (v$Version)"

}

Needs to be given a parameter value for changing the install parameters in the Stubpath during deployment creation but otherwise works as expected.

The detection script matches installer version against registry Version value, failing if either are missing or mis-match.

# Detection Script: Active Setup Verification
Invoke-ImmyCommand {
$UpgradeCode = $Using:UpgradeCode

# Generate deterministic GUID from UpgradeCode (must match remediation script)
$guidBytes = [System.Text.Encoding]::UTF8.GetBytes($UpgradeCode)
$hash      = [System.Security.Cryptography.MD5]::Create().ComputeHash($guidBytes)
$guid      = "{$([System.Guid]::new($hash))}"

$registryBase = "HKLM:\SOFTWARE\Microsoft\Active Setup\Installed Components\$guid"

# Step 1: Detect existence of Active Setup registry key
if (-not (Test-Path $registryBase)) {
    Write-Host "Active Setup registry key not found: $registryBase"
    return $null
}

# Step 2: Extract staged installer path from StubPath registry value
$stubPath = (Get-ItemProperty -Path $registryBase -Name "StubPath" -ErrorAction SilentlyContinue).StubPath

if (-not $stubPath) {
    Write-Host "StubPath value missing from registry key."
    return $null
}

# StubPath is in format: msiexec.exe /i "C:\ProgramData\ImmyBot\ActiveSetup\{UpgradeCode}\installer.msi" /qn /norestart
# Extract the quoted path from the StubPath value
$stagedFile = [regex]::Match($stubPath, '(?<="/)[^"]+(?=")').Value
# Re-add the leading quote character that the path starts after
$stagedFile = ([regex]::Match($stubPath, '"([^"]+)"')).Groups[1].Value

if (-not $stagedFile) {
    Write-Host "Could not parse installer path from StubPath: $stubPath"
    return $null
}

# Step 3: Detect existence of staged installer file
if (-not (Test-Path $stagedFile)) {
    Write-Host "Staged installer file not found: $stagedFile"
    return $null
}

# Step 4a: Extract version from staged MSI file
$windowsInstaller = New-Object -ComObject WindowsInstaller.Installer
$msiDatabase      = $windowsInstaller.OpenDatabase($stagedFile, 0)
$view             = $msiDatabase.OpenView("SELECT Value FROM Property WHERE Property = 'ProductVersion'")
$view.Execute()
$record           = $view.Fetch()
$msiVersion       = $record.StringData(1)

[System.Runtime.InteropServices.Marshal]::ReleaseComObject($view) | Out-Null
[System.Runtime.InteropServices.Marshal]::ReleaseComObject($msiDatabase) | Out-Null
[System.Runtime.InteropServices.Marshal]::ReleaseComObject($windowsInstaller) | Out-Null

# Convert MSI dot version to Active Setup comma format
$versionParts = $msiVersion.Split('.')
while ($versionParts.Count -lt 4) { $versionParts += "0" }
$msiVersionFormatted = $versionParts[0..3] -join ","

# Step 4b: Get Active Setup version from registry
$activeSetupVersion = (Get-ItemProperty -Path $registryBase -Name "Version" -ErrorAction SilentlyContinue).Version

if (-not $activeSetupVersion) {
    Write-Host "Active Setup Version key missing from registry."
    return $null
}

# Step 5: Compare versions and return if matching
if ($msiVersionFormatted -eq $activeSetupVersion) {
    Write-Host "Version match confirmed: $msiVersionFormatted"
    return $msiVersion
} else {
    Write-Host "Version mismatch - MSI: $msiVersionFormatted | Active Setup: $activeSetupVersion"
    return $null
}
}