#!/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 } # --- Inline site-config reader (this script runs from C:\PreInstall\installers\opentext\, # NOT from C:\Enrollment\shopfloor-setup\, so it can't dot-source Get-PCProfile.ps1) --- function Get-SiteConfig { $configPath = 'C:\Enrollment\site-config.json' if (-not (Test-Path $configPath)) { return $null } try { return Get-Content $configPath -Raw | ConvertFrom-Json } catch { return $null } } # --- 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..." # --- Resolve exclude lists from site-config.json (falls back to West Jefferson defaults) --- $siteConfig = Get-SiteConfig $profileExcludes = if ($siteConfig -and $siteConfig.opentext -and $siteConfig.opentext.excludeProfiles) { @($siteConfig.opentext.excludeProfiles) } else { @('WJ_Office.hep', 'IBM_qks.hep', 'mmcs.hep') # West Jefferson defaults } $shortcutExcludes = if ($siteConfig -and $siteConfig.opentext -and $siteConfig.opentext.excludeShortcuts) { @($siteConfig.opentext.excludeShortcuts) } else { @('WJ_Office.lnk', 'IBM_qks.lnk', 'mmcs.lnk') } if ($siteConfig) { Write-SetupLog "Site config loaded - profile excludes: $($profileExcludes -join ', ')" Write-SetupLog "Site config loaded - shortcut excludes: $($shortcutExcludes -join ', ')" } else { Write-SetupLog "No site-config.json found - using West Jefferson defaults for excludes" } # Map of source subdir -> destination subdir relative to the Hummingbird root. # Optional Exclude list drops specific filenames from both the source-to-dest # copy AND from the destination if they were left over from a prior install. $contentMap = @( @{ Src = 'Profile' Dst = 'Profile' Exclude = $profileExcludes } @{ 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 # Remove any previously-deployed excluded files from the destination # - handles the case where a PC got them from an older install. if ($entry.Exclude) { foreach ($name in $entry.Exclude) { $stale = Join-Path $dstPath $name if (Test-Path -LiteralPath $stale) { try { Remove-Item -LiteralPath $stale -Force -ErrorAction Stop Write-SetupLog " $Label : removed stale $name" } catch { Write-SetupLog " $Label : failed to remove $stale : $_" } } } } $files = Get-ChildItem -Path $srcPath -File -ErrorAction SilentlyContinue if ($entry.Exclude) { $files = @($files | Where-Object { $entry.Exclude -notcontains $_.Name }) } 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 --- # Uses $shortcutExcludes (resolved from site-config.json above) to skip # deploying unwanted .lnk files AND remove any a prior install left behind. Write-SetupLog "" Write-SetupLog "Step 4: Deploying public desktop shortcuts..." $shortcutSrc = Join-Path $SourceDir 'W10shortcuts' $publicDesktop = 'C:\Users\Public\Desktop' # Clean up stale copies from prior installs first foreach ($name in $shortcutExcludes) { $stale = Join-Path $publicDesktop $name if (Test-Path -LiteralPath $stale) { try { Remove-Item -LiteralPath $stale -Force -ErrorAction Stop Write-SetupLog " removed stale desktop shortcut: $name" } catch { Write-SetupLog " failed to remove stale $stale : $_" } } } if (Test-Path $shortcutSrc) { $lnkFiles = Get-ChildItem -Path $shortcutSrc -Filter '*.lnk' -File -ErrorAction SilentlyContinue foreach ($l in $lnkFiles) { if ($shortcutExcludes -contains $l.Name) { Write-SetupLog " skip (excluded): $($l.Name)" continue } 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