xetup/scripts/02-software.ps1
X9 Dev d30767ef8b
Some checks failed
release / build-and-release (push) Failing after 32s
fix: comprehensive reliability and robustness improvements
Critical fixes:
- Fix resume mode: StepsByIDs returned Enabled=false, all resume steps
  would be SKIPPED (deployment could never resume after reboot)
- Add reboot loop protection: per-step retry counter (max 5) prevents
  infinite reboot cycles when a step always exits with code 9
- Block reboot when state.Save() fails in resumePhase (prevents state
  loss leading to full restart from scratch)
- Atomic state file write (write-to-tmp + rename) prevents JSON
  corruption on BSOD/power loss mid-write
- Script watchdog: kills scripts after 30 min of no output (resets on
  each line, so active long-running scripts are never killed)
- Fix copyFile: check Close() error explicitly instead of deferred
  close that silently drops flush errors (e.g. disk full)

High severity:
- Cleanup() now logs errors instead of silently ignoring them
- Email report: 3 retries with backoff + always saves C:\X9\report.html
- Winget parallel jobs: 10 min timeout, kill hung jobs
- UCPD stop verification: 2s wait + state check before PDF association
- Atera installer: /qn -> /qb so MFA window can appear
- GVLK activation: match by EditionID (registry, not localized) instead
  of fragile OS caption string matching

Medium severity:
- Default profile hive unload: retry loop (5 attempts, increasing delay)
- LayoutModification.xml: UTF-8 without BOM (PS 5.1 Set-Content adds BOM)
- Set-Reg SYSTEM task: try/finally ensures temp file + task cleanup
- Windows Update: @($available).Count for PS 5.1 single-result edge case
- config.json: add missing kmsServer field in activation section

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-28 11:49:43 +02:00

248 lines
11 KiB
PowerShell

<#
.SYNOPSIS
Installs standard business software via winget, sets Adobe PDF default, and installs Atera RMM agent.
.DESCRIPTION
Uses winget to install the standard X9.cz MSP software bundle. Checks winget
availability before running. Each install is logged. After Adobe Acrobat Reader,
temporarily stops the UCPD driver (User Choice Protection Driver, present since
Win11 23H2 / Win10 22H2 update) to allow the HKCR file association write, sets
.pdf -> AcroRd32, then restarts UCPD. Atera RMM agent is installed for MSP
monitoring, remote access, and ticketing integration.
.ITEMS
7-zip-7zip-7zip: Installs 7-Zip (winget ID: 7zip.7zip). Used for archive management. Silent install with --accept-package-agreements --accept-source-agreements flags required for unattended deployment.
adobe-acrobat-reader-64-bit-adobe-acroba: Installs Adobe Acrobat Reader DC 64-bit (Adobe.Acrobat.Reader.64-bit). Required as the default PDF viewer to prevent Edge from handling PDFs in browser mode, which limits functionality.
openvpn-connect-openvpntechnologies-open: Installs OpenVPN Connect client. Used for client VPN access when the client network requires a VPN. The ovpn profile and credentials are configured separately per client.
atera-agent-install: Atera RMM agent downloaded from x9.servicedesk.atera.com and installed via msiexec /qb. During install, Atera MSI shows an interactive MFA window - technician enters the code to complete registration. Agent enables MSP monitoring, remote access, and ticketing integration.
adobe-pdf-default-pdf-acrord32-po-instal: Sets .pdf -> AcroRd32 file association after Acrobat install via HKCR (system-wide, no UserChoice hash issue). UCPD driver is stopped immediately before the write and restarted after to ensure the association persists across Edge updates.
ucpd-sys-kernel-driver-od-feb-2024-bloku: UCPD.sys (User Choice Protection Driver) is stopped before the PDF association write and restarted after. Pattern: Stop-Service ucpd -> set HKCR\.pdf -> Start-Service ucpd. Implemented in this script.
#>
param(
[string]$ConfigPath,
[string]$LogFile
)
. "$PSScriptRoot\common.ps1"
$Config = Load-Config $ConfigPath
# -----------------------------------------------------------------------
# Check winget availability
# -----------------------------------------------------------------------
Write-Log "Checking winget availability" -Level INFO
$wingetExe = $null
$wingetCmd = Get-Command winget -ErrorAction SilentlyContinue
if ($wingetCmd) {
$wingetExe = $wingetCmd.Source
} else {
# Try to find winget in known locations
$wingetPaths = @(
"$env:LOCALAPPDATA\Microsoft\WindowsApps\winget.exe"
"$env:ProgramFiles\WindowsApps\Microsoft.DesktopAppInstaller*\winget.exe"
)
foreach ($p in $wingetPaths) {
$found = Get-Item $p -ErrorAction SilentlyContinue | Select-Object -First 1
if ($found) { $wingetExe = $found.FullName; break }
}
}
if (-not $wingetExe) {
Write-Log "winget not found - software installation skipped" -Level ERROR
exit 1
}
Write-Log "winget found: $wingetExe" -Level OK
# Accept agreements upfront
& $wingetExe source update --accept-source-agreements 2>&1 | Out-Null
# -----------------------------------------------------------------------
# Install packages from config
# -----------------------------------------------------------------------
if (Get-Feature $Config "software" "wingetInstalls") {
if (-not $Config -or -not $Config.software -or -not $Config.software.install) {
Write-Log "No software list in config - skipping installs" -Level WARN
} else {
$packages = @($Config.software.install)
Write-Log "Installing $($packages.Count) packages in parallel" -Level INFO
# Launch all winget installs as parallel background jobs
$jobs = @()
foreach ($pkg in $packages) {
Write-Log " Starting: $($pkg.name) ($($pkg.wingetId))" -Level INFO
$jobs += Start-Job -ArgumentList $pkg.wingetId, $pkg.name, $wingetExe -ScriptBlock {
param($wingetId, $name, $wingetExe)
$output = & $wingetExe install --id $wingetId `
--silent `
--accept-package-agreements `
--accept-source-agreements `
--disable-interactivity `
2>&1
[PSCustomObject]@{
Name = $name
WingetId = $wingetId
ExitCode = $LASTEXITCODE
Output = ($output -join "`n")
}
}
}
# Wait for all jobs and collect results
Write-Log " Waiting for $($jobs.Count) installs to complete..." -Level INFO
$jobs | Wait-Job -Timeout 600 | Out-Null
# Kill any jobs that are still running after timeout
foreach ($job in $jobs) {
if ($job.State -eq "Running") {
Write-Log " Timeout: $($job.Name) - killing" -Level ERROR
Stop-Job -Job $job
}
}
foreach ($job in $jobs) {
$r = Receive-Job -Job $job
if ($r.ExitCode -eq 0 -or $r.ExitCode -eq 3010) {
Write-Log " Installed OK: $($r.Name)" -Level OK
} elseif ($r.ExitCode -eq -1978335189) {
Write-Log " Already installed: $($r.Name)" -Level OK
} else {
Write-Log " Failed: $($r.Name) (exit $($r.ExitCode))" -Level ERROR
Write-Log " Output: $($r.Output)" -Level ERROR
}
Remove-Job -Job $job
}
}
} else {
Write-Log "wingetInstalls feature disabled - skipping" -Level INFO
}
# -----------------------------------------------------------------------
# Set Adobe Reader as default PDF app
# -----------------------------------------------------------------------
if (Get-Feature $Config "software" "pdfDefault") {
Write-Log "Setting Adobe Reader as default PDF app" -Level INFO
# Stop UCPD driver before writing file association.
# UCPD.sys (User Choice Protection Driver) blocks UserChoice registry writes
# on Win11 23H2+ and some Win10 22H2 builds. Stopping it temporarily allows
# the HKCR association to be written reliably.
$ucpdStopped = $false
$ucpdSvc = Get-Service -Name "ucpd" -ErrorAction SilentlyContinue
if ($ucpdSvc) {
try {
Stop-Service -Name "ucpd" -Force -ErrorAction Stop
# Wait for the service to fully stop before writing association
Start-Sleep -Seconds 2
$svcState = (Get-Service -Name "ucpd" -ErrorAction SilentlyContinue).Status
if ($svcState -eq "Stopped") {
$ucpdStopped = $true
Write-Log " UCPD driver stopped" -Level OK
} else {
Write-Log " UCPD still in state '$svcState' - association may not persist" -Level WARN
}
}
catch {
Write-Log " Could not stop UCPD: $_ (association may not persist on some builds)" -Level WARN
}
}
# Find Adobe PDF viewer executable (Acrobat DC or Reader DC)
$acroPaths = @(
"$env:ProgramFiles\Adobe\Acrobat DC\Acrobat\Acrobat.exe"
"${env:ProgramFiles(x86)}\Adobe\Acrobat DC\Acrobat\Acrobat.exe"
"${env:ProgramFiles(x86)}\Adobe\Acrobat Reader DC\Reader\AcroRd32.exe"
"$env:ProgramFiles\Adobe\Acrobat Reader DC\Reader\AcroRd32.exe"
"${env:ProgramFiles(x86)}\Adobe\Reader\Reader\AcroRd32.exe"
)
$acroExe = $acroPaths | Where-Object { Test-Path $_ } | Select-Object -First 1
if (-not $acroExe) {
Write-Log " Adobe PDF viewer not found - PDF default not set" -Level WARN
} else {
Write-Log " Found: $acroExe" -Level INFO
# Mount HKCR PSDrive - not available by default in PS sessions
if (-not (Get-PSDrive -Name HKCR -ErrorAction SilentlyContinue)) {
New-PSDrive -Name HKCR -PSProvider Registry -Root HKEY_CLASSES_ROOT | Out-Null
}
# Set file type association via HKCR (system-wide, requires admin)
$progId = "AcroExch.Document.DC"
$openCmd = "`"$acroExe`" `"%1`""
# HKCR\.pdf -> progId
if (-not (Test-Path "HKCR:\.pdf")) {
New-Item -Path "HKCR:\.pdf" -Force | Out-Null
}
Set-ItemProperty -Path "HKCR:\.pdf" -Name "(Default)" -Value $progId
# HKCR\AcroExch.Document.DC\shell\open\command
$cmdPath = "HKCR:\$progId\shell\open\command"
if (-not (Test-Path $cmdPath)) {
New-Item -Path $cmdPath -Force | Out-Null
}
Set-ItemProperty -Path $cmdPath -Name "(Default)" -Value $openCmd
Write-Log " PDF default set to AcroRd32 (HKCR)" -Level OK
}
# Restart UCPD after writing association
if ($ucpdStopped) {
try {
Start-Service -Name "ucpd" -ErrorAction SilentlyContinue
Write-Log " UCPD driver restarted" -Level OK
}
catch {
Write-Log " Could not restart UCPD: $_" -Level WARN
}
}
} else {
Write-Log "pdfDefault feature disabled - skipping" -Level INFO
}
# -----------------------------------------------------------------------
# Install Atera RMM Agent
# Download MSI from Atera dashboard API, install via msiexec /qb.
# During install, the Atera MSI shows an interactive MFA window -
# the technician enters the code to complete agent registration.
# -----------------------------------------------------------------------
if (Get-Feature $Config "software" "ateraAgent") {
Write-Log "Installing Atera RMM Agent" -Level INFO
$ateraMsi = "$env:TEMP\AteraAgent.msi"
$ateraUrl = "https://x9.servicedesk.atera.com/api/utils/agent-install/windows/?cid=31&aeid=50b72e7113e54a63ac76b96c54c7e337"
try {
Write-Log " Downloading Atera MSI..." -Level INFO
Invoke-WebRequest -Uri $ateraUrl -OutFile $ateraMsi -UseBasicParsing -ErrorAction Stop
Write-Log " Download complete" -Level OK
Write-Log " Running installer (MFA window will appear)..." -Level INFO
$msiProc = Start-Process msiexec -ArgumentList "/i `"$ateraMsi`" /qb" -Wait -PassThru
if ($msiProc.ExitCode -eq 0) {
Write-Log " Atera agent installed (msiexec exit 0)" -Level OK
} else {
Write-Log " Atera agent install exit code: $($msiProc.ExitCode)" -Level WARN
}
# Verify binary exists
$ateraExe = "$env:ProgramFiles\ATERA Networks\AteraAgent\AteraAgent.exe"
$ateraExe86 = "${env:ProgramFiles(x86)}\ATERA Networks\AteraAgent\AteraAgent.exe"
if ((Test-Path $ateraExe) -or (Test-Path $ateraExe86)) {
Write-Log " Atera agent binary verified" -Level OK
} else {
Write-Log " Atera agent binary not found at expected paths" -Level WARN
}
}
catch {
Write-Log " Atera agent install failed: $_" -Level ERROR
}
finally {
Remove-Item $ateraMsi -ErrorAction SilentlyContinue
}
} else {
Write-Log "ateraAgent feature disabled - skipping" -Level INFO
}
Write-Log "Step 2 complete" -Level OK