# 00-PreInstall-MachineApps.ps1 - Install pre-staged apps from C:\PreInstall (Shopfloor baseline) # # Reads C:\PreInstall\preinstall.json (staged by startnet.cmd during WinPE phase) and # installs each app whose PCTypes filter matches the current PC type. Detection runs first # so already-installed apps are skipped. Numbered 00- so it runs before all other Shopfloor # baseline scripts - apps need to exist before later scripts (e.g. 01-eDNC.ps1) reference # them or rely on their state. # # Failure mode: log + continue. Intune DSC (Simple-Install.ps1) is the safety net for any # install that fails here. # # Logs to C:\Logs\PreInstall\install.log (wiped each run). $preInstallDir = "C:\PreInstall" $jsonPath = Join-Path $preInstallDir "preinstall.json" $installerDir = Join-Path $preInstallDir "installers" $logDir = "C:\Logs\PreInstall" $logFile = Join-Path $logDir "install.log" # --- Setup logging --- # IMPORTANT: do NOT wipe the log on each run. The runner can get killed by an # installer-triggered reboot mid-execution. On the next autologon, the dispatcher # re-runs us - if we wipe the log here, we destroy the forensic record from the # previous (interrupted) run. Instead, append a session header so runs are visible # but history is preserved. if (-not (Test-Path $logDir)) { New-Item -Path $logDir -ItemType Directory -Force | Out-Null } function Write-PreInstallLog { param( [Parameter(Mandatory=$true, Position=0)] [string]$Message, [Parameter(Position=1)] [ValidateSet('INFO','WARN','ERROR')] [string]$Level = 'INFO' ) $stamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" $line = "[$stamp] [$Level] $Message" Write-Host $line # Synchronous write-through so each line hits disk immediately, even if a system # reboot kills the process right after this call returns. Bypasses the OS write # cache via FileOptions.WriteThrough. try { $fs = New-Object System.IO.FileStream( $logFile, [System.IO.FileMode]::Append, [System.IO.FileAccess]::Write, [System.IO.FileShare]::Read, 4096, [System.IO.FileOptions]::WriteThrough ) $bytes = [System.Text.Encoding]::UTF8.GetBytes($line + "`r`n") $fs.Write($bytes, 0, $bytes.Length) $fs.Flush() $fs.Dispose() } catch { # Last-resort fallback if the FileStream open fails for any reason Add-Content -Path $logFile -Value $line -ErrorAction SilentlyContinue } } # Session header so multiple runs are visually distinguishable in the log Write-PreInstallLog "================================================================" Write-PreInstallLog "=== Runner session start (PID $PID) ===" Write-PreInstallLog "================================================================" # --- Bail early if no preinstall bundle staged --- if (-not (Test-Path $jsonPath)) { Write-PreInstallLog "No preinstall.json at $jsonPath - skipping (no bundle staged for this PC)." exit 0 } # --- Read PCTYPE from C:\Enrollment\pc-type.txt (set by startnet.cmd) --- $pcTypeFile = "C:\Enrollment\pc-type.txt" if (-not (Test-Path $pcTypeFile)) { Write-PreInstallLog "No pc-type.txt at $pcTypeFile - skipping" "WARN" exit 0 } $pcType = (Get-Content $pcTypeFile -First 1).Trim() if (-not $pcType) { Write-PreInstallLog "pc-type.txt is empty - skipping" "WARN" exit 0 } Write-PreInstallLog "PC type: $pcType" # --- Parse JSON --- try { $config = Get-Content $jsonPath -Raw | ConvertFrom-Json } catch { Write-PreInstallLog "Failed to parse $jsonPath : $_" "ERROR" exit 0 } if (-not $config.Applications) { Write-PreInstallLog "No Applications in preinstall.json" exit 0 } Write-PreInstallLog "Staged installer dir: $installerDir" Write-PreInstallLog "Found $($config.Applications.Count) app entries in preinstall.json" # --- Detection helper (mirrors Simple-Install.ps1's Test-ApplicationInstalled) --- function Test-AppInstalled { param($App) if (-not $App.DetectionMethod) { return $false } try { switch ($App.DetectionMethod) { "Registry" { if (-not (Test-Path $App.DetectionPath)) { return $false } if ($App.DetectionName) { $value = Get-ItemProperty -Path $App.DetectionPath -Name $App.DetectionName -ErrorAction SilentlyContinue if (-not $value) { return $false } if ($App.DetectionValue) { return ($value.$($App.DetectionName) -eq $App.DetectionValue) } return $true } return $true } "File" { return Test-Path $App.DetectionPath } default { Write-PreInstallLog " Unknown detection method: $($App.DetectionMethod)" "WARN" return $false } } } catch { Write-PreInstallLog " Detection check threw: $_" "WARN" return $false } } # --- Iterate apps --- $installed = 0 $skipped = 0 $failed = 0 foreach ($app in $config.Applications) { # Cancel any reboot a previous installer scheduled (some respect /norestart, some # don't - VC++ 2008's bootstrapper sometimes triggers an immediate Windows reboot # despite the flag). Doing this BEFORE each install protects the rest of the loop. cmd /c "shutdown /a 2>nul" *>$null Write-PreInstallLog "==> $($app.Name)" # Filter by PCTypes $allowedTypes = @($app.PCTypes) $matchesType = ($allowedTypes -contains "*") -or ($allowedTypes -contains $pcType) if (-not $matchesType) { Write-PreInstallLog " PCTypes filter excludes '$pcType' (allowed: $($allowedTypes -join ', ')) - skipping" $skipped++ continue } # Detection check if (Test-AppInstalled -App $app) { Write-PreInstallLog " Already installed - skipping" $skipped++ continue } # Locate installer file $installerPath = Join-Path $installerDir $app.Installer if (-not (Test-Path $installerPath)) { Write-PreInstallLog " Installer file not found: $installerPath" "ERROR" $failed++ continue } Write-PreInstallLog " Installing from $installerPath" if ($app.InstallArgs) { Write-PreInstallLog " InstallArgs: $($app.InstallArgs)" } # Per-app verbose msiexec log path - written by /L*v on MSI installs and tailed # into the runner log on failure so we don't need to dig through C:\Logs manually. $safeName = $app.Name -replace '[^a-zA-Z0-9]','_' $msiLog = Join-Path $logDir "msi-$safeName.log" if (Test-Path $msiLog) { Remove-Item $msiLog -Force -ErrorAction SilentlyContinue } try { # We use [System.Diagnostics.Process]::Start() directly instead of Start-Process # because PowerShell 5.1's Start-Process -PassThru has a bug where the returned # Process object's OS handle is disposed when control returns to the script, # causing ExitCode to always read as $null even after WaitForExit() succeeds. # Calling Process.Start() directly returns a Process object with a live handle # that exposes ExitCode correctly. We poll HasExited so UDC_Setup.exe (which # hangs forever after spawning a hidden WPF window) can be killed via the # detection-during-install path. $psi = New-Object System.Diagnostics.ProcessStartInfo $psi.UseShellExecute = $false $psi.CreateNoWindow = $true $psi.WindowStyle = [System.Diagnostics.ProcessWindowStyle]::Hidden if ($app.Type -eq "MSI") { $psi.FileName = "msiexec.exe" $psi.Arguments = "/i `"$installerPath`"" if ($app.InstallArgs) { $psi.Arguments += " " + $app.InstallArgs } $psi.Arguments += " /L*v `"$msiLog`"" Write-PreInstallLog " msiexec verbose log: $msiLog" } elseif ($app.Type -eq "EXE") { $psi.FileName = $installerPath if ($app.InstallArgs) { $psi.Arguments = $app.InstallArgs } # If the JSON entry specifies a LogFile (per-installer log written by # the EXE itself - e.g. Setup-OpenText.ps1 -> C:\Logs\PreInstall\Setup- # OpenText.log), surface it on failure the same way we surface the # msiexec /L*v verbose log for MSI installs. Lets EXE wrappers actually # report what went wrong inside. if ($app.LogFile) { Write-PreInstallLog " Installer log: $($app.LogFile)" } } else { Write-PreInstallLog " Unsupported Type: $($app.Type) - skipping" "ERROR" $failed++ continue } $proc = [System.Diagnostics.Process]::Start($psi) # Poll for completion: process exit OR detection success (whichever happens first) $timeoutSec = 600 # 10 min hard cap per app $pollInterval = 5 $elapsed = 0 $killedAfterDetect = $false while ($elapsed -lt $timeoutSec) { if ($proc.HasExited) { break } # Detection-during-install kill is OPT-IN via "KillAfterDetection: true" # in the JSON entry. It's only safe for installers that hang forever after # creating their detection markers (UDC_Setup.exe spawns a hidden WPF window # and never exits). For normal installers (Oracle, msiexec, etc.) the # detection registry key often gets written midway through the install AND # msiexec is still doing post-install cleanup AND msiserver is still holding # the install mutex - killing msiexec at that point leaves the system in a # bad state and the NEXT msiexec call returns 1618 ERROR_INSTALL_ALREADY_ # RUNNING. So opt-out by default; only kill apps that explicitly need it. if ($app.KillAfterDetection -and (Test-AppInstalled -App $app)) { Write-PreInstallLog " Detection passed at $elapsed s - killing installer to advance (KillAfterDetection)" try { $proc.Kill(); $proc.WaitForExit(5000) | Out-Null } catch { } # UDC's installer auto-launches UDC.exe in silent mode. Kill that too # so it can't write the placeholder MachineNumber to udc_settings.json. if ($app.Name -eq "UDC") { Get-Process UDC -ErrorAction SilentlyContinue | ForEach-Object { try { $_.Kill(); $_.WaitForExit(2000) | Out-Null } catch { } } } $killedAfterDetect = $true break } Start-Sleep -Seconds $pollInterval $elapsed += $pollInterval } if (-not $proc.HasExited -and -not $killedAfterDetect) { Write-PreInstallLog " TIMEOUT after $timeoutSec seconds - killing installer" "ERROR" try { $proc.Kill() } catch { } if ($app.Name -eq "UDC") { Get-Process UDC -ErrorAction SilentlyContinue | ForEach-Object { try { $_.Kill() } catch { } } } $failed++ continue } if ($killedAfterDetect) { Write-PreInstallLog " SUCCESS (verified via detection during install)" $installed++ } else { # Start-Process -PassThru returns a Process object whose ExitCode is null # until WaitForExit() is called - even if HasExited is already true. Without # this, every install that goes through the exit-code branch gets reported # as "Exit code - FAILED" (empty) regardless of the real result. try { $proc.WaitForExit() } catch { } $exitCode = $proc.ExitCode if ($exitCode -eq 0 -or $exitCode -eq 3010) { Write-PreInstallLog " Exit code $exitCode after $elapsed s - SUCCESS" if ($exitCode -eq 3010) { Write-PreInstallLog " (Reboot required for $($app.Name))" } $installed++ } else { Write-PreInstallLog " Exit code $exitCode - FAILED" "ERROR" # If the JSON entry specifies a LogFile for an EXE install (e.g. # Setup-OpenText.ps1 writes its own log at C:\Logs\PreInstall\Setup- # OpenText.log), tail it here so the runner log surfaces the actual # cause without us having to dig through C:\Logs manually. if ($app.Type -eq "EXE" -and $app.LogFile -and (Test-Path $app.LogFile)) { Write-PreInstallLog " --- last 30 lines of $($app.LogFile) ---" Get-Content $app.LogFile -Tail 30 -ErrorAction SilentlyContinue | ForEach-Object { Write-PreInstallLog " $_" } Write-PreInstallLog " --- end installer log tail ---" } # Surface the *meaningful* lines from the verbose msiexec log (not just # the tail, which is rollback/cleanup noise). The actual root-cause lines # are: MSI error notes (Note: 1: ), failed action returns (return # value 3), errors logged by custom actions, and any line containing the # word "Error". Grep these out so the runner log shows the smoking gun. if ($app.Type -eq "MSI" -and (Test-Path $msiLog)) { Write-PreInstallLog " --- meaningful lines from $msiLog ---" $patterns = @( 'Note: 1: ', 'return value 3', 'Error \d+\.', 'CustomAction .* returned actual error', 'Failed to ', 'Installation failed', 'Calling custom action', '1: 2262', # Database not found '1: 2203', # Cannot open database '1: 2330' # Insufficient permissions ) $regex = ($patterns -join '|') $matches = Select-String -Path $msiLog -Pattern $regex -ErrorAction SilentlyContinue | Select-Object -First 30 if ($matches) { foreach ($m in $matches) { Write-PreInstallLog " $($m.Line.Trim())" } } else { Write-PreInstallLog " (no error patterns matched - falling back to last 25 lines)" Get-Content $msiLog -Tail 25 -ErrorAction SilentlyContinue | ForEach-Object { Write-PreInstallLog " $_" } } Write-PreInstallLog " --- end MSI log scan ---" } $failed++ } } } catch { Write-PreInstallLog " Install threw: $_" "ERROR" $failed++ } } Write-PreInstallLog "============================================" Write-PreInstallLog "PreInstall complete: $installed installed, $skipped skipped, $failed failed" Write-PreInstallLog "============================================" # Final reboot cancel - if the last installer in the loop scheduled one, the # dispatcher's later `shutdown /a` won't fire until the next baseline script starts. # Cancel here so control returns cleanly to Run-ShopfloorSetup.ps1. cmd /c "shutdown /a 2>nul" *>$null exit 0