diff --git a/playbook/preinstall/opentext/README.md b/playbook/preinstall/opentext/README.md new file mode 100644 index 0000000..f31812d --- /dev/null +++ b/playbook/preinstall/opentext/README.md @@ -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/` + +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`. diff --git a/playbook/preinstall/opentext/Setup-OpenText.cmd b/playbook/preinstall/opentext/Setup-OpenText.cmd new file mode 100644 index 0000000..bb6b8b6 --- /dev/null +++ b/playbook/preinstall/opentext/Setup-OpenText.cmd @@ -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% diff --git a/playbook/preinstall/opentext/Setup-OpenText.ps1 b/playbook/preinstall/opentext/Setup-OpenText.ps1 new file mode 100644 index 0000000..865cbd1 --- /dev/null +++ b/playbook/preinstall/opentext/Setup-OpenText.ps1 @@ -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 +# +# 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\ +# \AppData\Roaming\Hummingbird\Connectivity\15.00\Profile\ +# \AppData\Roaming\Hummingbird\Connectivity\15.00\Accessories\EB\ +# \AppData\Roaming\Hummingbird\Connectivity\15.00\HostExplorer\Keymap\ +# \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 diff --git a/playbook/preinstall/opentext/version.txt b/playbook/preinstall/opentext/version.txt new file mode 100644 index 0000000..32979d9 --- /dev/null +++ b/playbook/preinstall/opentext/version.txt @@ -0,0 +1 @@ +15.0.SP1.2 diff --git a/playbook/preinstall/preinstall.json b/playbook/preinstall/preinstall.json index 5b8a004..67cec47 100644 --- a/playbook/preinstall/preinstall.json +++ b/playbook/preinstall/preinstall.json @@ -100,10 +100,12 @@ "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", "Installer": "UDC_Setup.exe", "Type": "EXE", "InstallArgs": "\"West Jefferson\" 9999", + "KillAfterDetection": true, "DetectionMethod": "Registry", "DetectionPath": "HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\UDC", "PCTypes": ["Standard"] diff --git a/playbook/shopfloor-setup/Shopfloor/00-PreInstall-MachineApps.ps1 b/playbook/shopfloor-setup/Shopfloor/00-PreInstall-MachineApps.ps1 index 88f6e6b..87c7fd5 100644 --- a/playbook/shopfloor-setup/Shopfloor/00-PreInstall-MachineApps.ps1 +++ b/playbook/shopfloor-setup/Shopfloor/00-PreInstall-MachineApps.ps1 @@ -244,11 +244,17 @@ foreach ($app in $config.Applications) { while ($elapsed -lt $timeoutSec) { if ($proc.HasExited) { break } - # If detection passes mid-install, the installer already did its job - - # we can kill any zombie process (like UDC_Setup.exe waiting on its hidden - # WPF window) and move on. - if (Test-AppInstalled -App $app) { - Write-PreInstallLog " Detection passed at $elapsed s - killing installer to advance" + # 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