OpenText: track Setup-OpenText scripts in repo, opt-in KillAfterDetection

Two related fixes from a debugging round on the test PC:

1. PreInstall runner: detection-during-install kill is now opt-in via
   "KillAfterDetection: true" on JSON entries that need it. Old behavior
   killed any installer as soon as its detection passed - which broke
   Oracle: Oracle creates its registry key partway through install,
   the runner detected it at the 25s poll, killed msiexec mid-install,
   and msiserver was still doing rollback when the next install (VC++
   2008) started - so VC++ 2008 hit ERROR_INSTALL_ALREADY_RUNNING
   (1618). Only UDC needs the detection-kill (its installer spawns a
   hidden WPF window and never exits). Other installers exit cleanly
   on their own and shouldn't be killed.

2. Track Setup-OpenText scripts in git. The bundled OpenText install
   scripts (Setup-OpenText.ps1, Setup-OpenText.cmd, version.txt) live
   at runtime in /home/camp/pxe-images/main/dependencies/opentext/
   alongside the binary install files (~106 MB of MSI/CAB/MSP/MST plus
   profile content). The binaries stay outside git but the script
   logic and version stamp are mirrored into playbook/preinstall/
   opentext/ here so git history captures changes to the install
   logic and version bumps. README.md explains the workflow.

   Latest Setup-OpenText.ps1 includes:
     - $SourceDir default moved into script body (PowerShell evaluates
       param([string]$X = $PSScriptRoot) defaults at parameter-binding
       time, when $PSScriptRoot may not yet be populated, so the
       default came out as empty string and Join-Path crashed)
     - Logging set up FIRST so any startup error gets captured
     - REBOOT=ReallySuppress dropped from both msiexec calls (base MSI
       and SP1 patch) - OpenText installs shell extensions that hook
       explorer.exe, and Restart Manager closes explorer to replace
       the shell DLLs. With REBOOT=ReallySuppress, RM closed explorer
       but interpreted the relaunch as a "reboot action" and refused
       to do it, leaving the user with no desktop. /norestart on its
       own prevents the actual Windows reboot but lets RM cleanly
       close-and-relaunch explorer mid-install.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-04-09 12:08:07 -04:00
parent 5eacd1d596
commit cd00d6d2e1
6 changed files with 337 additions and 5 deletions

View File

@@ -0,0 +1,38 @@
# OpenText HostExplorer ShopFloor — install scripts
These three files are git-tracked snapshots of what lives at runtime
in `/home/camp/pxe-images/main/dependencies/opentext/`. The full
runtime tree also contains the bundled installer binaries (~106 MB
total) which are intentionally NOT in git:
```
OpenTextHostExplorer15x64.msi ~16 MB
OpenTextHostExplorer15x64.cab ~86 MB
OpenTextHostExplorer15x64_ServicePack1.msp ~4.4 MB
ShopFloorx64.mst ~20 KB
Profile/ 5 .hep connection profiles
Accessories/EB/ 6 .ebs/.ebx macros
HostExplorer/Keymap/ 2 .kmv keymaps
HostExplorer/Menu/ 2 .hmv menu layouts
W10shortcuts/ 4 .lnk public-desktop shortcuts
```
The canonical source for everything (scripts AND binaries) is
`/home/camp/pxe-images/main/dependencies/opentext/`. The files in
this dir are mirrors that exist so changes to the install logic and
the version stamp end up in git history. When editing:
1. Edit `/home/camp/pxe-images/main/dependencies/opentext/Setup-OpenText.ps1`
(or `.cmd` / `version.txt`)
2. Re-run `bash playbook/sync-preinstall.sh` to push the runtime tree
to the live PXE server
3. `cp` the changed file(s) into `playbook/preinstall/opentext/` here
so git picks up the change
4. Re-upload the changed file(s) to Azure Blob at
`prod/main/dependencies/opentext/<filename>`
To bump OpenText version: edit `version.txt` only - the value flows
through to Setup-OpenText.ps1 (which reads it at runtime), to
Install-OpenText.ps1 (the DSC wrapper, which downloads version.txt
first as a cheap detection check), and to the registry marker at
`HKLM:\SOFTWARE\GE\OpenText\Installed`.

View File

@@ -0,0 +1,11 @@
@echo off
REM Setup-OpenText.cmd - tiny launcher for Setup-OpenText.ps1
REM
REM The PXE PreInstall runner only invokes Type:MSI or Type:EXE entries from
REM preinstall.json. This .cmd lives next to Setup-OpenText.ps1 in the staged
REM opentext\ subtree and just hands off to PowerShell with bypass policy.
REM Both files end up at C:\PreInstall\installers\opentext\ via xcopy /E from
REM the WinPE staging step in startnet.cmd.
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0Setup-OpenText.ps1"
exit /b %errorlevel%

View File

@@ -0,0 +1,274 @@
#!/usr/bin/env pwsh
# Setup-OpenText.ps1 - OpenText HostExplorer 15 SP1 ShopFloor installer + profile
# deployment, callable from both PXE PreInstall and Intune DSC paths.
#
# WHY THIS EXISTS:
# The vendor-supplied OpenText.exe (Inno Setup wrapper built by WJDT) bundles
# the install steps but its [Files] section deploys per-user content to
# {userappdata} - which resolves to SYSTEM's profile under DSC and to a single
# user under PreInstall. As a result the operator (logging in via Azure AD)
# never sees the profiles, keymaps, menus, or macros - only the installed
# binaries. This script replaces OpenText.exe entirely, doing the same install
# steps via direct msiexec calls AND fanning the per-user content out to:
# - %ProgramData%\Hummingbird\Connectivity\15.00\Shared\
# - C:\Users\Default\AppData\Roaming\Hummingbird\Connectivity\15.00\
# - Each existing user profile under C:\Users\
#
# INVOKED BY:
# - PreInstall: Setup-OpenText.cmd wrapper (because the runner only knows MSI/EXE)
# - DSC: Install-OpenText.ps1 downloads the bundled tree from blob, then
# invokes this script with -SourceDir <temp dir>
#
# DETECTION:
# Skips if HKLM:\SOFTWARE\GE\OpenText\Installed = $expectedVersion. Marker is
# written at the end of a successful run, so the runner / DSC wrapper can
# no-op on subsequent invocations.
[CmdletBinding()]
param(
# Override when invoked from a temp dir (DSC path) where the bundled files were
# just downloaded. Defaults to $PSScriptRoot, but resolved INSIDE the script body
# below - PowerShell evaluates `param([string]$X = $PSScriptRoot)` at parameter-
# binding time, when $PSScriptRoot may not yet be populated, so the default winds
# up as an empty string. Setting it in the body works because $PSScriptRoot is
# reliably populated by then.
[string]$SourceDir
)
$ErrorActionPreference = 'Stop'
if (-not $SourceDir) {
$SourceDir = $PSScriptRoot
}
# --- Logging (set up FIRST so any startup error - missing version.txt, broken
# bundled file, etc. - lands in the log file instead of disappearing into the
# runner's stdout void) ---
$logDir = 'C:\Logs\PreInstall'
$logFile = Join-Path $logDir 'Setup-OpenText.log'
$msiLog = Join-Path $logDir 'Setup-OpenText-msi.log'
$mspLog = Join-Path $logDir 'Setup-OpenText-msp.log'
if (-not (Test-Path $logDir)) {
New-Item -Path $logDir -ItemType Directory -Force | Out-Null
}
function Write-SetupLog {
param([string]$Message)
$line = "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')] $Message"
Add-Content -Path $logFile -Value $line -ErrorAction SilentlyContinue
Write-Host $line
}
Write-SetupLog "================================================================"
Write-SetupLog "=== Setup-OpenText.ps1 starting ==="
Write-SetupLog "================================================================"
Write-SetupLog "SourceDir: $SourceDir"
Write-SetupLog "PSScriptRoot: $PSScriptRoot"
Write-SetupLog "Running as: $([System.Security.Principal.WindowsIdentity]::GetCurrent().Name)"
# --- Config ---
# Version is read from version.txt next to this script. ONE source of truth: bumping
# version.txt is the only edit needed when shipping a new OpenText build. Setup-
# OpenText.ps1 itself, Install-OpenText.ps1 (DSC wrapper), and the registry marker
# all derive their notion of "expected version" from this file.
$versionFile = Join-Path $SourceDir 'version.txt'
Write-SetupLog "Looking for version.txt at: $versionFile"
if (-not (Test-Path $versionFile)) {
Write-SetupLog "ERROR: version.txt not found in $SourceDir - cannot determine expected version."
Write-SetupLog "Directory listing of $SourceDir :"
if (Test-Path $SourceDir) {
Get-ChildItem $SourceDir -ErrorAction SilentlyContinue | ForEach-Object {
Write-SetupLog " $($_.Name) $($_.Length) bytes"
}
} else {
Write-SetupLog " (SourceDir does not exist)"
}
exit 1
}
$expectedVersion = (Get-Content -Path $versionFile -Raw -ErrorAction Stop).Trim()
if (-not $expectedVersion) {
Write-SetupLog "ERROR: version.txt at $versionFile is empty."
exit 1
}
Write-SetupLog "Expected version (from version.txt): $expectedVersion"
# --- Detection: skip if already deployed at expected version ---
$markerKey = 'HKLM:\SOFTWARE\GE\OpenText'
$markerVal = 'Installed'
if (Test-Path $markerKey) {
$installed = (Get-ItemProperty -Path $markerKey -Name $markerVal -ErrorAction SilentlyContinue).$markerVal
if ($installed -eq $expectedVersion) {
Write-SetupLog "OpenText $expectedVersion already deployed (marker present) - skipping."
exit 0
}
Write-SetupLog "OpenText marker mismatch (found '$installed', expected '$expectedVersion') - re-deploying."
}
else {
Write-SetupLog "OpenText marker not present - first install."
}
# --- Verify bundled files are where we expect ---
$msiPath = Join-Path $SourceDir 'OpenTextHostExplorer15x64.msi'
$cabPath = Join-Path $SourceDir 'OpenTextHostExplorer15x64.cab'
$mspPath = Join-Path $SourceDir 'OpenTextHostExplorer15x64_ServicePack1.msp'
$mstPath = Join-Path $SourceDir 'ShopFloorx64.mst'
foreach ($f in @($msiPath, $cabPath, $mspPath, $mstPath)) {
if (-not (Test-Path $f)) {
Write-SetupLog "ERROR: required file not found: $f"
exit 1
}
}
# --- Step 1: Install the base MSI with the ShopFloor transform ---
# NOTE: We deliberately do NOT pass REBOOT=ReallySuppress here even though we do
# for the VC++ MSIs. OpenText HostExplorer installs shell extensions that hook
# explorer.exe, and the MSI uses Restart Manager to ask explorer to close so the
# in-use shell DLLs can be replaced. With REBOOT=ReallySuppress, RM closes
# explorer.exe but interprets "restart explorer" as a reboot action and refuses
# to relaunch it - leaving the user without a desktop. /norestart on its own
# prevents the actual Windows reboot but lets RM cleanly close-and-relaunch
# explorer mid-install. msiexec still returns 3010 ("reboot would be needed"),
# which we treat as success below.
Write-SetupLog ""
Write-SetupLog "Step 1: Installing OpenTextHostExplorer15x64.msi with ShopFloorx64.mst..."
if (Test-Path $msiLog) { Remove-Item $msiLog -Force -ErrorAction SilentlyContinue }
$msiArgs = "/i `"$msiPath`" TRANSFORMS=`"$mstPath`" /qn /norestart /L*v `"$msiLog`""
Write-SetupLog " msiexec.exe $msiArgs"
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = 'msiexec.exe'
$psi.Arguments = $msiArgs
$psi.UseShellExecute = $false
$psi.CreateNoWindow = $true
$proc = [System.Diagnostics.Process]::Start($psi)
$proc.WaitForExit()
$msiExit = $proc.ExitCode
Write-SetupLog " msiexec exit code: $msiExit"
if ($msiExit -ne 0 -and $msiExit -ne 3010) {
Write-SetupLog "ERROR: base MSI install failed (exit $msiExit). See $msiLog"
exit 1
}
if ($msiExit -eq 3010) {
Write-SetupLog " (3010 = reboot needed but suppressed)"
}
# --- Step 2: Apply Service Pack 1 patch ---
# Same Restart Manager rationale as Step 1 - skip REBOOT=ReallySuppress so RM
# can relaunch explorer.exe after replacing the patched shell extension DLLs.
Write-SetupLog ""
Write-SetupLog "Step 2: Applying SP1 patch..."
if (Test-Path $mspLog) { Remove-Item $mspLog -Force -ErrorAction SilentlyContinue }
$mspArgs = "/p `"$mspPath`" /qn /norestart /L*v `"$mspLog`""
Write-SetupLog " msiexec.exe $mspArgs"
$psi.Arguments = $mspArgs
$proc = [System.Diagnostics.Process]::Start($psi)
$proc.WaitForExit()
$mspExit = $proc.ExitCode
Write-SetupLog " msiexec exit code: $mspExit"
if ($mspExit -ne 0 -and $mspExit -ne 3010) {
Write-SetupLog "ERROR: SP1 patch failed (exit $mspExit). See $mspLog"
exit 1
}
# --- Step 3: Deploy profiles, keymaps, menus, accessories ---
# Source layout (bundled with this script):
# $SourceDir\Profile\*.hep
# $SourceDir\Accessories\EB\*.eb*
# $SourceDir\HostExplorer\Keymap\*.kmv
# $SourceDir\HostExplorer\Menu\*.hmv
#
# Target layouts (Hummingbird-canonical):
# %ProgramData%\Hummingbird\Connectivity\15.00\Shared\Profile\
# %ProgramData%\Hummingbird\Connectivity\15.00\Shared\Accessories\EB\
# %ProgramData%\Hummingbird\Connectivity\15.00\Shared\HostExplorer\Keymap\
# %ProgramData%\Hummingbird\Connectivity\15.00\Shared\HostExplorer\Menu\
# <user>\AppData\Roaming\Hummingbird\Connectivity\15.00\Profile\
# <user>\AppData\Roaming\Hummingbird\Connectivity\15.00\Accessories\EB\
# <user>\AppData\Roaming\Hummingbird\Connectivity\15.00\HostExplorer\Keymap\
# <user>\AppData\Roaming\Hummingbird\Connectivity\15.00\HostExplorer\Menu\
#
# We deploy to ProgramData\Shared (system-wide fallback), Default User (template
# inherited by every NEW user profile), and every existing user profile (so
# already-created accounts like SupportUser get them immediately).
Write-SetupLog ""
Write-SetupLog "Step 3: Deploying profiles/keymaps/menus/macros..."
# Map of source subdir -> destination subdir relative to the Hummingbird root
$contentMap = @(
@{ Src = 'Profile'; Dst = 'Profile' }
@{ Src = 'Accessories\EB'; Dst = 'Accessories\EB' }
@{ Src = 'HostExplorer\Keymap'; Dst = 'HostExplorer\Keymap' }
@{ Src = 'HostExplorer\Menu'; Dst = 'HostExplorer\Menu' }
)
function Copy-HummingbirdContent {
param(
[string]$RootDst, # Hummingbird root, e.g. C:\ProgramData\Hummingbird\Connectivity\15.00\Shared
[string]$Label
)
foreach ($entry in $contentMap) {
$srcPath = Join-Path $SourceDir $entry.Src
if (-not (Test-Path $srcPath)) { continue }
$dstPath = Join-Path $RootDst $entry.Dst
New-Item -Path $dstPath -ItemType Directory -Force | Out-Null
$files = Get-ChildItem -Path $srcPath -File -ErrorAction SilentlyContinue
foreach ($f in $files) {
Copy-Item -Path $f.FullName -Destination $dstPath -Force
}
Write-SetupLog " $Label : $($entry.Src) -> $dstPath ($($files.Count) files)"
}
}
# 3a. ProgramData Shared
$sharedRoot = Join-Path $env:ProgramData 'Hummingbird\Connectivity\15.00\Shared'
Copy-HummingbirdContent -RootDst $sharedRoot -Label 'Shared'
# 3b. Default User (template for new user profiles)
$defaultUserRoot = 'C:\Users\Default\AppData\Roaming\Hummingbird\Connectivity\15.00'
Copy-HummingbirdContent -RootDst $defaultUserRoot -Label 'Default User'
# 3c. Every existing user profile under C:\Users\
$skipNames = @('Default', 'Default User', 'Public', 'defaultuser0', 'All Users', 'WDAGUtilityAccount')
$userDirs = Get-ChildItem 'C:\Users' -Directory -ErrorAction SilentlyContinue |
Where-Object { $skipNames -notcontains $_.Name -and (Test-Path "$($_.FullName)\AppData\Roaming") }
foreach ($u in $userDirs) {
$userRoot = Join-Path $u.FullName 'AppData\Roaming\Hummingbird\Connectivity\15.00'
Copy-HummingbirdContent -RootDst $userRoot -Label $u.Name
}
# --- Step 4: Public Desktop shortcuts ---
Write-SetupLog ""
Write-SetupLog "Step 4: Deploying public desktop shortcuts..."
$shortcutSrc = Join-Path $SourceDir 'W10shortcuts'
$publicDesktop = 'C:\Users\Public\Desktop'
if (Test-Path $shortcutSrc) {
$lnkFiles = Get-ChildItem -Path $shortcutSrc -Filter '*.lnk' -File -ErrorAction SilentlyContinue
foreach ($l in $lnkFiles) {
Copy-Item -Path $l.FullName -Destination $publicDesktop -Force
Write-SetupLog " $($l.Name) -> $publicDesktop"
}
}
# --- Step 5: Write registry marker ---
Write-SetupLog ""
Write-SetupLog "Step 5: Writing registry marker..."
if (-not (Test-Path $markerKey)) {
New-Item -Path $markerKey -Force | Out-Null
}
Set-ItemProperty -Path $markerKey -Name $markerVal -Value $expectedVersion -Force
Set-ItemProperty -Path $markerKey -Name 'InstalledAt' -Value (Get-Date -Format 'o') -Force
Write-SetupLog ""
Write-SetupLog "================================================================"
Write-SetupLog "=== OpenText HostExplorer ShopFloor $expectedVersion deployed ==="
Write-SetupLog "================================================================"
exit 0

View File

@@ -0,0 +1 @@
15.0.SP1.2

View File

@@ -100,10 +100,12 @@
"PCTypes": ["Standard"] "PCTypes": ["Standard"]
}, },
{ {
"_comment": "UDC_Setup.exe spawns a hidden WPF window (UDC.exe) after install and never exits, so the runner needs KillAfterDetection: true to terminate UDC_Setup.exe + UDC.exe once the registry detection passes. This is an OPT-IN flag - normal installers should NOT set it because killing msiexec mid-install leaves msiserver holding the install mutex and the next msiexec call returns 1618 (Oracle hit this exact bug).",
"Name": "UDC", "Name": "UDC",
"Installer": "UDC_Setup.exe", "Installer": "UDC_Setup.exe",
"Type": "EXE", "Type": "EXE",
"InstallArgs": "\"West Jefferson\" 9999", "InstallArgs": "\"West Jefferson\" 9999",
"KillAfterDetection": true,
"DetectionMethod": "Registry", "DetectionMethod": "Registry",
"DetectionPath": "HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\UDC", "DetectionPath": "HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\UDC",
"PCTypes": ["Standard"] "PCTypes": ["Standard"]

View File

@@ -244,11 +244,17 @@ foreach ($app in $config.Applications) {
while ($elapsed -lt $timeoutSec) { while ($elapsed -lt $timeoutSec) {
if ($proc.HasExited) { break } if ($proc.HasExited) { break }
# If detection passes mid-install, the installer already did its job - # Detection-during-install kill is OPT-IN via "KillAfterDetection: true"
# we can kill any zombie process (like UDC_Setup.exe waiting on its hidden # in the JSON entry. It's only safe for installers that hang forever after
# WPF window) and move on. # creating their detection markers (UDC_Setup.exe spawns a hidden WPF window
if (Test-AppInstalled -App $app) { # and never exits). For normal installers (Oracle, msiexec, etc.) the
Write-PreInstallLog " Detection passed at $elapsed s - killing installer to advance" # 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 { } try { $proc.Kill(); $proc.WaitForExit(5000) | Out-Null } catch { }
# UDC's installer auto-launches UDC.exe in silent mode. Kill that too # UDC's installer auto-launches UDC.exe in silent mode. Kill that too