# Install-FromManifest.ps1 - Generic JSON-manifest runner for shopfloor apps. # # Stage 2a extension: adds PS1, BAT, File, Registry, INF action types and # Always / MarkerFile / ValueMatches / pnputil detection methods. Also adds # optional PCTypes filtering (entry skipped if current pc-type.txt doesn't # match any value in the entry's PCTypes array). # # Manifest-schema fields parsed-but-not-yet-acted-on (Stage 2b will wire them): # ApplyMode, UpdateWindow, InUseCheck. The lib treats all entries as # immediate-apply today; Stage 2b adds shift-window gating and # close-and-reopen behavior. # # Consumers: # - GE-Enforce.ps1 (new unified dispatcher, runs via scheduled task with # both logon and periodic triggers) # - Legacy *-Enforce.ps1 that still point here (will be retired) # # Exit codes: # 0 = every required entry either already satisfied or installed successfully # 1 = at least one entry failed # 2 = manifest or installer-root could not be read param( [Parameter(Mandatory=$true)] [string]$ManifestPath, [Parameter(Mandatory=$true)] [string]$InstallerRoot, [Parameter(Mandatory=$true)] [string]$LogFile, [Parameter(Mandatory=$false)] [string]$PCType, [Parameter(Mandatory=$false)] [string]$PCSubType ) $ErrorActionPreference = 'Continue' # Lib's supported manifest schema version. Bump the MAJOR part whenever a # manifest field or behavior changes in a way older libs can't handle. # Bump the MINOR part for additive, backward-compatible additions (new # optional field, new Type, new DetectionMethod). Manifests tagged with # a newer MAJOR than the lib get processed best-effort with unknown Types # logged; manifests tagged with a newer MINOR are fine. # # Changelog: # 2.5 - Type=EXE handler honors optional WaitTimeoutSec on the manifest # entry. WiX Burn bootstrappers (UDC_Setup.exe) install the MSI # successfully but the wrapper process never exits (waits on a # bundled child service). With WaitTimeoutSec set, kill the # wrapper after timeout, re-check Test-Installed - if pass, # treat as success (rc 0); if fail, surface as -2. # 2.4 - Type=EXE handler stages network-share EXEs to a local temp dir # before invoking Process.Start. SYSTEM-context Process.Start # fails with "Access is denied" on \\share or mapped-drive EXE # paths (empirically confirmed with UDC_Setup.exe 2026-05-02). # Local invocation works. Cleanup is best-effort in finally. # 2.3 - PCTypes filter accepts old (Standard, Standard-Machine, CMM, ...) # and new (gea-shopfloor-collections, gea-shopfloor-cmm, ...) names # interchangeably via alias sets. Transitional for the rename reorg. # 2.2 - added TargetMachineNumbers filter (reads C:\Enrollment\machine-number.txt # then falls back to DNC registry HKLM\...\GE Aircraft Engines\DNC\General\MachineNo) # 2.1 - added TargetHostnames filter (exact + -like wildcards) # 2.0 - initial Stage 2a: PS1/BAT/File/Registry/INF action types, # Always/MarkerFile/ValueMatches/pnputil detection, PCTypes filter $LIB_MANIFEST_MAJOR = 2 $LIB_MANIFEST_MINOR = 5 $logDir = Split-Path -Parent $LogFile if (-not (Test-Path $logDir)) { New-Item -Path $logDir -ItemType Directory -Force | Out-Null } function Write-InstallLog { 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 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 { Add-Content -Path $LogFile -Value $line -ErrorAction SilentlyContinue } } Write-InstallLog '================================================================' Write-InstallLog "=== Install-FromManifest session start (PID $PID) ===" Write-InstallLog "Manifest: $ManifestPath" Write-InstallLog "InstallerRoot: $InstallerRoot" if ($PCType) { Write-InstallLog "PCType: $PCType" } if ($PCSubType) { Write-InstallLog "PCSubType: $PCSubType" } Write-InstallLog '================================================================' if (-not (Test-Path -LiteralPath $ManifestPath)) { Write-InstallLog "Manifest not found: $ManifestPath" 'ERROR' exit 2 } if (-not (Test-Path -LiteralPath $InstallerRoot)) { Write-InstallLog "InstallerRoot not found: $InstallerRoot" 'ERROR' exit 2 } try { $config = Get-Content -LiteralPath $ManifestPath -Raw | ConvertFrom-Json } catch { Write-InstallLog "Failed to parse manifest: $_" 'ERROR' exit 2 } if (-not $config.Applications -or $config.Applications.Count -eq 0) { Write-InstallLog 'No Applications in manifest - nothing to do' exit 0 } # --------------------------------------------------------------------------- # Manifest schema version check. Accepts "X" or "X.Y" form. If the manifest # declares a MAJOR higher than the lib supports, log a clear warning so the # operator sees "upgrade the lib on this PC" instead of weird silent # skip-everything behavior. The lib still tries to run (unknown Types will # log as ERROR and count as failed, but supported entries work). # --------------------------------------------------------------------------- $manifestMajor = 0 $manifestMinor = 0 if ($config.Version) { $parts = "$($config.Version)".Split('.') if ($parts.Length -ge 1 -and [int]::TryParse($parts[0], [ref]$null)) { $manifestMajor = [int]$parts[0] } if ($parts.Length -ge 2 -and [int]::TryParse($parts[1], [ref]$null)) { $manifestMinor = [int]$parts[1] } } if ($manifestMajor -gt $LIB_MANIFEST_MAJOR) { Write-InstallLog " Manifest schema version $($config.Version) is newer than this lib (supports $LIB_MANIFEST_MAJOR.$LIB_MANIFEST_MINOR). Unknown Types/fields will be logged as errors. Upgrade the lib on this PC." 'WARN' } elseif ($manifestMajor -eq $LIB_MANIFEST_MAJOR -and $manifestMinor -gt $LIB_MANIFEST_MINOR) { Write-InstallLog " Manifest schema $($config.Version) is minor-newer than lib $LIB_MANIFEST_MAJOR.$LIB_MANIFEST_MINOR. Should be backward compatible; any unknown Types will be logged." 'INFO' } Write-InstallLog "Manifest lists $($config.Applications.Count) app(s)" # --------------------------------------------------------------------------- # Detection # --------------------------------------------------------------------------- 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 } 'FileVersion' { if (-not (Test-Path $App.DetectionPath)) { return $false } if (-not $App.DetectionValue) { Write-InstallLog ' FileVersion detection requires DetectionValue - treating as not installed' 'WARN' return $false } $actual = (Get-Item $App.DetectionPath -ErrorAction Stop).VersionInfo.FileVersion if (-not $actual) { return $false } return ($actual -eq $App.DetectionValue) } 'Hash' { if (-not (Test-Path $App.DetectionPath)) { return $false } if (-not $App.DetectionValue) { Write-InstallLog ' Hash detection requires DetectionValue - treating as not installed' 'WARN' return $false } $actual = (Get-FileHash -Path $App.DetectionPath -Algorithm SHA256 -ErrorAction Stop).Hash return ($actual -ieq $App.DetectionValue) } 'MarkerFile' { # Used for one-shot PS1 scripts. Presence of the marker file # means the script already ran successfully. The lib writes # the marker automatically after a 0 exit on PS1/BAT/CMD. if (-not $App.DetectionPath) { return $false } return Test-Path $App.DetectionPath } 'ValueMatches' { # For Type=Registry entries. Check that the registry value # equals the desired RegValue (read from the app entry). if (-not (Test-Path $App.DetectionPath)) { return $false } if (-not $App.DetectionName) { Write-InstallLog ' ValueMatches detection requires DetectionName' 'WARN' return $false } $p = Get-ItemProperty -Path $App.DetectionPath -Name $App.DetectionName -ErrorAction SilentlyContinue if (-not $p) { return $false } return ("$($p.$($App.DetectionName))" -eq "$($App.RegValue)") } 'pnputil' { # For INF drivers. Run pnputil /enum-drivers once and grep. if (-not $App.DetectionPattern) { Write-InstallLog ' pnputil detection requires DetectionPattern' 'WARN' return $false } $drivers = & pnputil /enum-drivers 2>&1 | Out-String return ($drivers -match $App.DetectionPattern) } 'Always' { # Never considered installed - entry runs every cycle. # Useful for idempotent scripts like the VNC firewall rule. return $false } default { Write-InstallLog " Unknown detection method: $($App.DetectionMethod)" 'WARN' return $false } } } catch { Write-InstallLog " Detection check threw: $_" 'WARN' return $false } } # --------------------------------------------------------------------------- # Action dispatch # --------------------------------------------------------------------------- function Invoke-InstallerAction { param($App) $psi = New-Object System.Diagnostics.ProcessStartInfo $psi.UseShellExecute = $false $psi.CreateNoWindow = $true $psi.WindowStyle = [System.Diagnostics.ProcessWindowStyle]::Hidden switch ($App.Type) { 'MSI' { $installerPath = Join-Path $InstallerRoot $App.Installer if (-not (Test-Path -LiteralPath $installerPath)) { Write-InstallLog " MSI not found: $installerPath" 'ERROR' return [pscustomobject]@{ ExitCode = -1; LogRef = $null } } $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 } $psi.FileName = 'msiexec.exe' $psi.Arguments = "/i `"$installerPath`"" if ($App.InstallArgs) { $psi.Arguments += " " + $App.InstallArgs } $psi.Arguments += " /L*v `"$msiLog`"" Write-InstallLog " msiexec: $installerPath" Write-InstallLog " verbose log: $msiLog" $proc = [System.Diagnostics.Process]::Start($psi) $proc.WaitForExit() return [pscustomobject]@{ ExitCode = $proc.ExitCode; LogRef = $msiLog } } 'EXE' { $installerPath = Join-Path $InstallerRoot $App.Installer if (-not (Test-Path -LiteralPath $installerPath)) { Write-InstallLog " EXE not found: $installerPath" 'ERROR' return [pscustomobject]@{ ExitCode = -1; LogRef = $null } } # Process.Start of an EXE that lives on a network share fails # with "Access is denied" when the dispatcher runs as SYSTEM, # even with a valid mount and no Mark-of-the-Web. Empirically # confirmed 2026-05-02 with UDC_Setup.exe via qga: same path # works once copied to a local C:\ dir. Stage to a per-cycle # temp dir, run from there, clean up afterwards. Costs the # transit time once per fire (most EXEs skip on subsequent # cycles via detection so this rarely repeats). $runPath = $installerPath $isNetworkPath = ($installerPath -match '^\\\\') -or ($installerPath -match '^[A-Z]:\\' -and (Get-PSDrive -Name $installerPath.Substring(0,1) -ErrorAction SilentlyContinue).DisplayRoot) $stagedPath = $null if ($isNetworkPath) { $stagedDir = Join-Path $env:TEMP ("ge-enforce-exe-" + [Guid]::NewGuid().ToString('N').Substring(0,8)) try { New-Item -ItemType Directory -Path $stagedDir -Force | Out-Null $leaf = Split-Path -Leaf $installerPath $stagedPath = Join-Path $stagedDir $leaf Copy-Item -LiteralPath $installerPath -Destination $stagedPath -Force -ErrorAction Stop $runPath = $stagedPath Write-InstallLog " staged network EXE -> $runPath" } catch { Write-InstallLog " failed to stage EXE locally: $_ - attempting direct invocation" 'WARN' } } $psi.FileName = $runPath if ($App.InstallArgs) { $psi.Arguments = $App.InstallArgs } Write-InstallLog " exe: $runPath" if ($App.InstallArgs) { Write-InstallLog " args: $($App.InstallArgs)" } # Optional WaitTimeoutSec on the manifest entry: some EXE wrappers # complete the install but don't exit themselves (UDC_Setup.exe is a # WiX Burn bootstrapper that hangs post-install waiting on a child # service that never returns control). When set, kill the wrapper # after the timeout AND re-check detection - if installed, treat as # success (rc 0); else surface as failure. Default unset = old # behavior (block forever via WaitForExit). $waitTimeoutMs = if ($App.WaitTimeoutSec) { [int]$App.WaitTimeoutSec * 1000 } else { -1 } try { $proc = [System.Diagnostics.Process]::Start($psi) if ($waitTimeoutMs -gt 0) { $exited = $proc.WaitForExit($waitTimeoutMs) if (-not $exited) { Write-InstallLog " WaitTimeoutSec=$($App.WaitTimeoutSec) reached - killing wrapper, will re-check detection" 'WARN' try { $proc.Kill(); $proc.WaitForExit(5000) | Out-Null } catch {} Start-Sleep -Seconds 2 if (Test-AppInstalled -App $App) { Write-InstallLog " detection passes post-kill - treating as success" $exitCode = 0 } else { Write-InstallLog " detection still missing post-kill - failure" 'ERROR' $exitCode = -2 } } else { $exitCode = $proc.ExitCode } } else { $proc.WaitForExit() $exitCode = $proc.ExitCode } } catch { Write-InstallLog " Process.Start failed: $_" 'ERROR' $exitCode = -1 } finally { if ($stagedPath) { try { Remove-Item -LiteralPath (Split-Path -Parent $stagedPath) -Recurse -Force -ErrorAction Stop } catch {} } } return [pscustomobject]@{ ExitCode = $exitCode; LogRef = $App.LogFile } } { $_ -eq 'CMD' -or $_ -eq 'BAT' } { $installerPath = Join-Path $InstallerRoot $App.Installer if (-not (Test-Path -LiteralPath $installerPath)) { Write-InstallLog " CMD/BAT not found: $installerPath" 'ERROR' return [pscustomobject]@{ ExitCode = -1; LogRef = $null } } $psi.FileName = 'cmd.exe' $psi.Arguments = "/c `"$installerPath`"" if ($App.InstallArgs) { $psi.Arguments += " " + $App.InstallArgs } Write-InstallLog " cmd /c $installerPath" $proc = [System.Diagnostics.Process]::Start($psi) $proc.WaitForExit() return [pscustomobject]@{ ExitCode = $proc.ExitCode; LogRef = $App.LogFile } } 'PS1' { $scriptPath = Join-Path $InstallerRoot ($App.Script) if (-not (Test-Path -LiteralPath $scriptPath)) { Write-InstallLog " PS1 not found: $scriptPath" 'ERROR' return [pscustomobject]@{ ExitCode = -1; LogRef = $null } } $psi.FileName = 'powershell.exe' $psi.Arguments = "-NoProfile -ExecutionPolicy Bypass -File `"$scriptPath`"" if ($App.Args) { $psi.Arguments += " " + $App.Args } Write-InstallLog " ps1: $scriptPath" if ($App.Args) { Write-InstallLog " args: $($App.Args)" } $proc = [System.Diagnostics.Process]::Start($psi) $proc.WaitForExit() return [pscustomobject]@{ ExitCode = $proc.ExitCode; LogRef = $null } } 'INF' { $infPath = Join-Path $InstallerRoot $App.Installer if (-not (Test-Path -LiteralPath $infPath)) { Write-InstallLog " INF not found: $infPath" 'ERROR' return [pscustomobject]@{ ExitCode = -1; LogRef = $null } } Write-InstallLog " pnputil /add-driver `"$infPath`" /install" $out = & pnputil /add-driver "$infPath" /install 2>&1 | Out-String Write-InstallLog " pnputil output: $($out.Trim())" # pnputil exits 0 on success, 259 if already installed, etc. return [pscustomobject]@{ ExitCode = $LASTEXITCODE; LogRef = $null } } 'File' { # Copy a file from the share (configs/*) to an absolute on-PC path. $source = Join-Path $InstallerRoot $App.Source if (-not (Test-Path -LiteralPath $source)) { Write-InstallLog " File source not found: $source" 'ERROR' return [pscustomobject]@{ ExitCode = -1; LogRef = $null } } if (-not $App.Destination) { Write-InstallLog ' File entry missing Destination' 'ERROR' return [pscustomobject]@{ ExitCode = -1; LogRef = $null } } $destDir = Split-Path -Parent $App.Destination if ($destDir -and -not (Test-Path $destDir)) { New-Item -Path $destDir -ItemType Directory -Force | Out-Null } try { Copy-Item -LiteralPath $source -Destination $App.Destination -Force -ErrorAction Stop Write-InstallLog " copied $source -> $($App.Destination)" return [pscustomobject]@{ ExitCode = 0; LogRef = $null } } catch { Write-InstallLog " Copy failed: $_" 'ERROR' return [pscustomobject]@{ ExitCode = 1; LogRef = $null } } } 'Registry' { # Write a single registry value. if (-not $App.RegPath -or -not $App.RegName) { Write-InstallLog ' Registry entry missing RegPath/RegName' 'ERROR' return [pscustomobject]@{ ExitCode = -1; LogRef = $null } } if (-not (Test-Path $App.RegPath)) { New-Item -Path $App.RegPath -Force | Out-Null } $type = if ($App.RegType) { $App.RegType } else { 'String' } try { Set-ItemProperty -Path $App.RegPath -Name $App.RegName -Value $App.RegValue -Type $type -Force -ErrorAction Stop Write-InstallLog " set $($App.RegPath)\$($App.RegName) = $($App.RegValue) ($type)" return [pscustomobject]@{ ExitCode = 0; LogRef = $null } } catch { Write-InstallLog " Registry write failed: $_" 'ERROR' return [pscustomobject]@{ ExitCode = 1; LogRef = $null } } } default { Write-InstallLog " Unsupported Type: $($App.Type)" 'ERROR' return [pscustomobject]@{ ExitCode = -2; LogRef = $null } } } } # --------------------------------------------------------------------------- # Entry-applies filter. An entry applies to this PC only if: # - PCTypes is omitted OR matches current pcType/pcType-subType/"*", AND # - TargetHostnames is omitted OR matches current COMPUTERNAME # (supports exact match and -like wildcards: "WJS-*", "*-SHOP-*"). # Both filters are ANDed so they compose: scope to a type AND a hostname # subset, or either alone. Case-insensitive throughout. # --------------------------------------------------------------------------- # PCTypes alias map for the 2026-05-03 rename reorg. Manifests may use # either old names (Standard, Standard-Machine, CMM, etc.) or new names # (gea-shopfloor-collections, gea-shopfloor-cmm, etc.). Each entry below # is a set of names that all match the same identity. The match logic # resolves the current PC's identity AND each PCTypes entry into their # alias sets, then matches if the sets intersect. See # project-shopfloor-rename-reorg memory for the full rename plan. $script:_pcTypeAliasGroups = @( @('Standard', 'gea-shopfloor-collections', 'gea-shopfloor-nocollections', 'gea-shopfloor-common'), @('Standard-Machine', 'gea-shopfloor-collections', 'gea-shopfloor-nocollections'), @('Standard-Timeclock', 'gea-shopfloor-common'), @('CMM', 'gea-shopfloor-cmm'), @('Keyence', 'gea-shopfloor-keyence'), @('Lab', 'gea-shopfloor-common'), @('WaxAndTrace', 'gea-shopfloor-waxtrace'), @('Genspect', 'gea-shopfloor-genspect'), @('Display', 'gea-shopfloor-display'), @('Heattreat', 'gea-shopfloor-heattreat') ) # Returns every alias set (each itself a string array) that contains $name. # Multiple groups can return the same name (e.g. "Standard" appears in the # super-group covering all three new shopfloor variants AND has its own # subtype-specific groups - by design, matches widely). function Get-PCTypeAliasSets { param([string]$Name) $hits = @() foreach ($g in $script:_pcTypeAliasGroups) { foreach ($n in $g) { if ($n -ieq $Name) { $hits += ,$g; break } } } return ,$hits } function Test-PCTypeMatches { param($App, [string]$Type, [string]$SubType) if (-not $App.PCTypes -or $App.PCTypes.Count -eq 0) { return $true } if (-not $Type) { return $true } # Build the set of strings the CURRENT PC matches: bare PCType, # "Type-SubType", and every alias-set member of either. $myNames = New-Object System.Collections.Generic.HashSet[string]([System.StringComparer]::OrdinalIgnoreCase) [void]$myNames.Add($Type) if ($SubType) { [void]$myNames.Add("$Type-$SubType") } foreach ($n in @($Type, "$Type-$SubType") | Where-Object { $_ }) { foreach ($g in (Get-PCTypeAliasSets -Name $n)) { foreach ($alias in $g) { [void]$myNames.Add($alias) } } } foreach ($t in $App.PCTypes) { if ($t -eq '*') { return $true } if ($myNames.Contains($t)) { return $true } # Manifest entry's PCTypes value may itself be an alias - expand it # and check overlap with the PC's identity set. foreach ($g in (Get-PCTypeAliasSets -Name $t)) { foreach ($alias in $g) { if ($myNames.Contains($alias)) { return $true } } } } return $false } function Test-HostnameMatches { param($App) if (-not $App.TargetHostnames -or $App.TargetHostnames.Count -eq 0) { return $true } # [System.Environment]::MachineName reads the live NetBIOS name from the # kernel. $env:COMPUTERNAME is cached in the process environment at PS # startup and is stale after a PC rename until the next reboot - which # matters on Intune-managed PCs that get renamed post-imaging. $myName = [System.Environment]::MachineName foreach ($h in $App.TargetHostnames) { if ($h -ieq $myName) { return $true } if ($myName -ilike $h) { return $true } # glob patterns: WJS-*, *-SHOP-* } return $false } # Machine-number filter. Stable identifier tied to the bay; survives PC # replacement at the same machine. Source of truth = the value the tech # entered at the PXE menu, persisted to C:\Enrollment\machine-number.txt # by startnet.cmd. Falls back to the DNC registry if that file is missing # (covers PCs that pre-date this filter being introduced). $script:_cachedMachineNumber = $null function Get-CurrentMachineNumber { if ($null -ne $script:_cachedMachineNumber) { return $script:_cachedMachineNumber } $candidates = @( 'C:\Enrollment\machine-number.txt' ) foreach ($p in $candidates) { if (Test-Path -LiteralPath $p) { $v = (Get-Content -LiteralPath $p -ErrorAction SilentlyContinue | Select-Object -First 1) if ($v) { $script:_cachedMachineNumber = $v.Trim(); return $script:_cachedMachineNumber } } } foreach ($r in @( 'HKLM:\SOFTWARE\WOW6432Node\GE Aircraft Engines\DNC\General', 'HKLM:\SOFTWARE\GE Aircraft Engines\DNC\General' )) { if (Test-Path $r) { $p = Get-ItemProperty -Path $r -ErrorAction SilentlyContinue if ($p.MachineNo) { $script:_cachedMachineNumber = [string]$p.MachineNo; return $script:_cachedMachineNumber } } } $script:_cachedMachineNumber = '' return '' } function Test-MachineNumberMatches { param($App) if (-not $App.TargetMachineNumbers -or $App.TargetMachineNumbers.Count -eq 0) { return $true } $myNumber = Get-CurrentMachineNumber if (-not $myNumber) { return $false } # entry restricts by machine #, but PC has no machine # -> exclude foreach ($n in $App.TargetMachineNumbers) { if ([string]$n -ieq $myNumber) { return $true } } return $false } # --------------------------------------------------------------------------- # Main loop # --------------------------------------------------------------------------- $installed = 0 $skipped = 0 $failed = 0 $pcFiltered = 0 foreach ($app in $config.Applications) { # Cancel any reboot that a prior MSI queued, so the enforcer never # triggers an unexpected restart on a shopfloor PC. cmd /c 'shutdown /a 2>nul' *>$null Write-InstallLog "==> $($app.Name)" if (-not (Test-PCTypeMatches -App $app -Type $PCType -SubType $PCSubType)) { Write-InstallLog " PCTypes filter: entry targets $($app.PCTypes -join ',') but PC is $PCType$(if ($PCSubType) { "-$PCSubType" }) - skipping" $pcFiltered++ continue } if (-not (Test-HostnameMatches -App $app)) { Write-InstallLog " TargetHostnames filter: entry targets $($app.TargetHostnames -join ',') but PC is $([System.Environment]::MachineName) - skipping" $pcFiltered++ continue } if (-not (Test-MachineNumberMatches -App $app)) { $myNum = Get-CurrentMachineNumber Write-InstallLog " TargetMachineNumbers filter: entry targets $($app.TargetMachineNumbers -join ',') but machine number is $(if ($myNum) { $myNum } else { '(none)' }) - skipping" $pcFiltered++ continue } if (Test-AppInstalled -App $app) { Write-InstallLog ' Already installed at expected version - skipping' $skipped++ continue } # --- InUseCheck (partial Stage 2b): ForceClose / CloseAndReopen --- # Before install, if the entry declares processes that would block the # install, close them. Stage 2a supports ForceClose: polite WM_CLOSE # with a timeout then hard Kill. "CloseAndReopen" is currently treated # the same (Stage 2b will add the user-session relaunch trick). No # reopen happens today; operator relaunches the app or the enforcer # leaves it for Windows Explorer / Start-Menu shortcut behavior. if ($app.InUseCheck -and $app.InUseCheck.Behavior -in @('ForceClose','CloseAndReopen')) { foreach ($p in ($app.InUseCheck.Processes | Where-Object { $_ })) { $procs = Get-Process -Name $p.Name -ErrorAction SilentlyContinue foreach ($proc in $procs) { $timeout = if ($p.GracefulCloseTimeoutSec) { [int]$p.GracefulCloseTimeoutSec } else { 10 } try { Write-InstallLog " InUseCheck: $($p.Name) (PID $($proc.Id)) asked to close (timeout ${timeout}s)" $null = $proc.CloseMainWindow() if (-not $proc.WaitForExit([int]($timeout * 1000))) { Write-InstallLog " InUseCheck: $($p.Name) did not exit gracefully - killing" 'WARN' $proc.Kill() $proc.WaitForExit(5000) | Out-Null } } catch { Write-InstallLog " InUseCheck: close/kill of $($p.Name) threw: $_" 'WARN' } } } } $result = Invoke-InstallerAction -App $app $rc = $result.ExitCode if ($rc -eq 0 -or $rc -eq 1641 -or $rc -eq 3010 -or $rc -eq 259) { Write-InstallLog " Exit $rc - SUCCESS" if ($rc -eq 3010) { Write-InstallLog " (Reboot pending for $($app.Name))" } if ($rc -eq 1641) { Write-InstallLog " (Installer initiated a reboot for $($app.Name))" } if ($rc -eq 259) { Write-InstallLog ' (pnputil: no newer driver found - considered installed)' } $installed++ # Auto-write marker file for MarkerFile-detected entries that just # completed successfully. Keeps one-shot PS1 scripts from running # twice (idempotent scripts can skip this by using Always detection). if ($app.DetectionMethod -eq 'MarkerFile' -and $app.DetectionPath) { $markerDir = Split-Path -Parent $app.DetectionPath if ($markerDir -and -not (Test-Path $markerDir)) { New-Item -Path $markerDir -ItemType Directory -Force | Out-Null } try { Set-Content -Path $app.DetectionPath -Value (Get-Date -Format 'o') -ErrorAction Stop Write-InstallLog " marker written: $($app.DetectionPath)" } catch { Write-InstallLog " marker write failed: $_" 'WARN' } } } else { Write-InstallLog " Exit $rc - FAILED" 'ERROR' if ($result.LogRef -and (Test-Path $result.LogRef)) { if ($app.Type -eq 'MSI') { Write-InstallLog " --- meaningful lines from $($result.LogRef) ---" $patterns = @( 'Note: 1: ', 'return value 3', 'Error \d+\.', 'CustomAction .* returned actual error', 'Failed to ', 'Installation failed', '1: 2262', '1: 2203', '1: 2330' ) $regex = ($patterns -join '|') $matches = Select-String -Path $result.LogRef -Pattern $regex -ErrorAction SilentlyContinue | Select-Object -First 30 if ($matches) { foreach ($m in $matches) { Write-InstallLog " $($m.Line.Trim())" } } else { Get-Content $result.LogRef -Tail 25 -ErrorAction SilentlyContinue | ForEach-Object { Write-InstallLog " $_" } } Write-InstallLog ' --- end MSI log scan ---' } else { Write-InstallLog " --- last 30 lines of $($result.LogRef) ---" Get-Content $result.LogRef -Tail 30 -ErrorAction SilentlyContinue | ForEach-Object { Write-InstallLog " $_" } Write-InstallLog ' --- end installer log tail ---' } } $failed++ } } Write-InstallLog '============================================' Write-InstallLog "Install-FromManifest complete: $installed installed, $skipped skipped, $failed failed, $pcFiltered pc-filtered" Write-InstallLog '============================================' cmd /c 'shutdown /a 2>nul' *>$null if ($failed -gt 0) { exit 1 } exit 0