Bring the share's common scripts under version control

Sixteen files that run on every shopfloor PC existed only on the SFLD share.
The cost showed up while debugging the NTLARS backup: the script that posts to
ShopDB could not be read, reviewed or diffed, so its behaviour was inferred
from log output for most of a day. It turned out to hold a silent fallback that
had been governing the whole fleet for months.

Imported as-is from tsgwp00525-v2, no edits:

  lib/ShopdbBackupClient.psm1        the shared backup client
  scripts/Backup-NtlarsSettings.ps1  converted to use it
  scripts/Set-ShopdbCollectorKey.ps1 collector credential delivery
  scripts/Test-RegExport.ps1         exercises the .reg codec with mocks
  scripts/Set-EventSaver*.ps1        kiosk power / screensaver / disable
  scripts/Setup-OpenText.*           OpenText install + toolbar
  scripts/Migrate-PCType.ps1, Select-KioskType.ps1, Set-FmsHostsEntry.ps1,
  scripts/ensure-vnc-firewall.ps1, Install-AcroReader.cmd, Install-Oracle11r2.cmd

lib/Install-FromManifest.ps1 is also updated from the share, which was 37 lines
AHEAD of this repo and purely additive: the Add-EnforceResult reporting added
during the kiosk API cutover, done live and never committed back. Nothing was
removed.

Checked for embedded secrets before committing; there are none.
Set-ShopdbCollectorKey deliberately reads its token from a sibling file on the
share rather than holding it, so the script is safe to track.

The share remains what actually runs. This makes it reviewable, and makes the
next drift visible as a diff rather than a surprise.
This commit is contained in:
cproudlock
2026-08-11 12:36:38 -04:00
parent b075939c38
commit 54cbe6b5d6
17 changed files with 1795 additions and 0 deletions

View File

@@ -643,6 +643,18 @@ $skipped = 0
$failed = 0
$pcFiltered = 0
# Per-entry outcomes for the caller's report (the API enforce runner captures
# the object emitted at the end). SMB/GE-Enforce.ps1 ignores stdout + reads the
# exit code, so this is additive and does not change the share path.
$script:enforceResults = [System.Collections.Generic.List[object]]::new()
function Add-EnforceResult {
param([string]$Name, [string]$Action, [int]$ExitCode = 0,
[bool]$SelfHealed = $false, [string]$Message = '')
$script:enforceResults.Add([pscustomobject]@{
Name = $Name; Action = $Action; ExitCode = $ExitCode
SelfHealed = $SelfHealed; Message = $Message })
}
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.
@@ -658,12 +670,14 @@ foreach ($app in $config.Applications) {
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++
Add-EnforceResult -Name $app.Name -Action 'filtered' -Message 'PCTypes filter'
continue
}
if (-not (Test-HostnameMatches -App $app)) {
Write-InstallLog " TargetHostnames filter: entry targets $($app.TargetHostnames -join ',') but PC is $([System.Environment]::MachineName) - skipping"
$pcFiltered++
Add-EnforceResult -Name $app.Name -Action 'filtered' -Message 'TargetHostnames filter'
continue
}
@@ -671,6 +685,7 @@ foreach ($app in $config.Applications) {
$myNum = Get-CurrentMachineNumber
Write-InstallLog " TargetMachineNumbers filter: entry targets $($app.TargetMachineNumbers -join ',') but machine number is $(if ($myNum) { $myNum } else { '(none)' }) - skipping"
$pcFiltered++
Add-EnforceResult -Name $app.Name -Action 'filtered' -Message 'TargetMachineNumbers filter'
continue
}
@@ -678,12 +693,14 @@ foreach ($app in $config.Applications) {
$myVer = Get-CurrentCmmVersion
Write-InstallLog " _CmmVersion filter: entry targets $($app._CmmVersion) but bay version is $(if ($myVer) { $myVer } else { '(none)' }) - skipping"
$pcFiltered++
Add-EnforceResult -Name $app.Name -Action 'filtered' -Message '_CmmVersion filter'
continue
}
if (Test-AppInstalled -App $app) {
Write-InstallLog ' Already installed at expected version - skipping'
$skipped++
Add-EnforceResult -Name $app.Name -Action 'skipped' -Message 'already installed'
continue
}
@@ -723,6 +740,12 @@ foreach ($app in $config.Applications) {
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++
# SelfHealed = a real drift correction (a detected-missing entry we
# re-installed). Always/no-detection entries install every cycle by
# design and are not self-heals, so the report stays 'ok' for them.
Add-EnforceResult -Name $app.Name -Action 'installed' -ExitCode $rc `
-SelfHealed ([bool]($app.DetectionMethod -and $app.DetectionMethod -ne 'Always')) `
-Message "Exit $rc"
# Auto-write marker file for MarkerFile-detected entries that just
# completed successfully. Keeps one-shot PS1 scripts from running
@@ -767,11 +790,13 @@ foreach ($app in $config.Applications) {
}
$failed++
Add-EnforceResult -Name $app.Name -Action 'failed' -ExitCode $rc -Message "Exit $rc - FAILED"
}
} catch {
Write-InstallLog (" UNCAUGHT error processing {0}: {1} | at {2}" -f $app.Name, $_.Exception.Message, ($_.ScriptStackTrace -replace '\s+',' ')) 'ERROR'
$failed++
Add-EnforceResult -Name $app.Name -Action 'failed' -Message $_.Exception.Message
}
}
@@ -781,5 +806,17 @@ Write-InstallLog '============================================'
cmd /c 'shutdown /a 2>nul' *>$null
# Emit the summary object for the API enforce runner to report. Write-Host log
# lines above go to the host stream, so this is the only value on the success
# stream that '& $EnginePath' captures. The exit code is unchanged (SMB path).
Write-Output ([pscustomobject]@{
Installed = $installed
Skipped = $skipped
Failed = $failed
Filtered = $pcFiltered
EnforcerVersion = "$LIB_MANIFEST_MAJOR.$LIB_MANIFEST_MINOR"
Results = $script:enforceResults.ToArray()
})
if ($failed -gt 0) { exit 1 }
exit 0

View File

@@ -0,0 +1,375 @@
# ShopdbBackupClient - the client half of the ShopDB asset-backup contract.
#
# WHY THIS EXISTS
#
# ShopDB's backups plugin is a pluggable registry: a "kind" declares how a
# config is parsed, rendered and resolved to an asset, and in return gets
# revision history, content dedup, retention, diffs and an asset panel. The
# server half is a clean contract. The client half was not: exactly one script
# on this share posted backups (Backup-NtlarsSettings), and everything around
# the post - finding the collector key, reading the interval, throttling,
# logging - lived inside it, bespoke.
#
# The cost showed up the day someone looked. The interval came from a public
# settings key the plugin never declared public, so the read silently fell back
# to a hardcoded 24 hours and the setting did nothing for months. The log wrote
# three lines every five minutes whatever happened, reaching 3,234 lines of
# which 3,217 were the same "Throttled" line. Seven other Backup-*.ps1 scripts
# on this share capture device configs and post NONE of them, so nothing about
# a CMM, a PC-DMIS bay or an MTConnect box has any history in ShopDB.
#
# Wiring those seven the old way would have produced seven more copies of all
# of the above. So the shared parts live here, once, and a per-device script
# becomes: capture the config, call Send-ShopdbBackup.
#
# USAGE
#
# Import-Module "$PSScriptRoot\..\lib\ShopdbBackupClient.psm1" -Force
# $ctx = Initialize-ShopdbBackup -Kind 'gocmm'
# if (-not $ctx.Proceed) { exit 0 } # quiet exit, already logged
# $bytes = [IO.File]::ReadAllBytes($configPath)
# Send-ShopdbBackup -Context $ctx -Bytes $bytes -SourceFileName 'settings.xml'
#
# Initialize-ShopdbBackup does every check that can say "nothing to do today":
# machine number, collector key, base URL, throttle. Each of those is logged
# ONCE and then stays quiet while it holds, so a PC with no NTLARS does not
# write the same line 288 times a day.
#
# PER-KIND STATE, deliberately. Marker, state and log files are all named from
# the kind, so a bay running two backup kinds does not have them fighting over
# one marker file - which is what a single fixed name would have caused the
# first time a second kind shipped.
Set-StrictMode -Version Latest
$script:LOGDIR = 'C:\Logs\Shopfloor'
$script:SHOPDBREG = 'HKLM:\SOFTWARE\GE\ShopDB'
$script:KEYFILE = 'C:\Enrollment\shopdb-key.txt'
$script:PCCONFIG = 'C:\Enrollment\pc-config.txt'
function Get-ShopdbLogPath {
param([Parameter(Mandatory)][string]$Kind)
# Date-stamped at source. An append-only name can never age out of the
# GE-Enforce retention sweep, which drops by LastWriteTime - a file written
# every cycle is always "recent" and grows forever.
Join-Path $script:LOGDIR ('{0}-backup-{1}.log' -f $Kind, (Get-Date -Format 'yyyyMMdd'))
}
function Write-ShopdbBackupLog {
param([Parameter(Mandatory)][string]$Kind,
[Parameter(Mandatory)][string]$Message)
if (-not (Test-Path $script:LOGDIR)) {
New-Item -ItemType Directory -Path $script:LOGDIR -Force -EA SilentlyContinue | Out-Null
}
$line = '[{0}] {1}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Message
Add-Content -Path (Get-ShopdbLogPath -Kind $Kind) -Value $line -EA SilentlyContinue
Write-Host $line
}
function Write-ShopdbQuietState {
<#
Log a no-change outcome once, then stay silent while it holds.
Returns nothing; the caller exits on a quiet outcome.
GE-Enforce runs every 5 minutes. An outcome that does not change is
worth saying once, not 288 times a day. Any real event clears the state
so the next quiet spell announces itself - otherwise a PC that stopped
working would look exactly like one still posting.
#>
param([Parameter(Mandatory)][string]$Kind,
[Parameter(Mandatory)][string]$State,
[Parameter(Mandatory)][string]$Message)
$statefile = Join-Path $script:LOGDIR ('{0}-backup.state' -f $Kind)
$previous = ''
if (Test-Path $statefile) {
try { $previous = (Get-Content -LiteralPath $statefile -First 1 -EA Stop).Trim() } catch { }
}
if ($previous -ne $State) {
Write-ShopdbBackupLog -Kind $Kind -Message $Message
Set-Content -Path $statefile -Value $State -EA SilentlyContinue
}
}
function Clear-ShopdbQuietState {
param([Parameter(Mandatory)][string]$Kind)
Remove-Item -LiteralPath (Join-Path $script:LOGDIR ('{0}-backup.state' -f $Kind)) `
-Force -EA SilentlyContinue
}
function Get-ShopdbRegValue {
<#
One value from HKLM:\SOFTWARE\GE\ShopDB.
The SAME contract the display kiosks use: Install-ShopdbKiosk writes
BaseUrl / ApiToken / CollectorKey there and ACLs it to SYSTEM and
Administrators. Machine bays read the same place rather than inventing
a second home for one secret, so one delivery mechanism serves the whole
fleet and the diagnostics collector already knows to redact it.
#>
param([Parameter(Mandatory)][string]$Name)
try {
$props = Get-ItemProperty -Path $script:SHOPDBREG -EA Stop
if ($props.PSObject.Properties.Name -contains $Name) {
$val = [string]$props.$Name
if ($val) { return $val.Trim() }
}
} catch { }
return ''
}
function Get-ShopdbCollectorKey {
# Registry first (how the fleet is provisioned), then the enrollment file
# for a bay staged before that existed.
$key = Get-ShopdbRegValue -Name 'CollectorKey'
if ($key) { return $key }
if (Test-Path $script:KEYFILE) {
foreach ($line in (Get-Content -LiteralPath $script:KEYFILE -EA SilentlyContinue)) {
if ($line -match '^\s*collector\s*=\s*(.+)$') { return $matches[1].Trim() }
}
}
return ''
}
function Get-ShopdbMachineNumber {
<#
The machine number this PC reports, from pc-config.txt.
A device-specific fallback can be supplied when the application itself
knows the number - NTLARS keeps one in its own registry key. Callers
without one just get '' and exit quietly.
#>
param([scriptblock]$Fallback)
if (Test-Path $script:PCCONFIG) {
foreach ($line in (Get-Content -LiteralPath $script:PCCONFIG -EA SilentlyContinue)) {
if ($line -match '^\s*machine(number|no)?\s*=\s*(.+)$') {
$val = $matches[2].Trim()
# 9999 is the imaging-time placeholder, not a real bay.
if ($val -and $val -ne '9999') { return $val }
}
}
}
if ($Fallback) {
try {
$val = & $Fallback
if ($val) { return ([string]$val).Trim() }
} catch { }
}
return ''
}
function Get-ShopdbIntervalHours {
<#
Minimum hours between attempts, from the backups_intervalhours setting.
Read UNAUTHENTICATED from /api/settings/public, because this runs before
any credential is needed. That endpoint serves an allowlist, and the key
must be declared public by the plugin - it was not, for months, so this
read returned nothing and the fallback below silently governed the whole
fleet. The fallback stays (an unreachable server must not mean a hot
loop) but a miss is now LOGGED rather than swallowed, so the same
failure cannot hide again.
#>
param([Parameter(Mandatory)][string]$Kind,
[Parameter(Mandatory)][string]$BaseUrl,
[int]$Default = 24)
try {
$resp = Invoke-RestMethod -Uri "$BaseUrl/api/settings/public" -Method Get `
-TimeoutSec 10 -EA Stop
$val = $null
if ($resp -and $resp.PSObject.Properties.Name -contains 'data') {
if ($resp.data.PSObject.Properties.Name -contains 'backups_intervalhours') {
$val = $resp.data.backups_intervalhours
}
}
if ($val) { return [int]$val }
Write-ShopdbQuietState -Kind $Kind -State 'interval-not-public' -Message (
'backups_intervalhours is not readable from /api/settings/public; ' +
"using the built-in ${Default}h. The plugin must declare the key public.")
} catch {
Write-ShopdbQuietState -Kind $Kind -State 'interval-unreachable' -Message (
"Could not read settings from $BaseUrl ($($_.Exception.Message)); using ${Default}h.")
}
return $Default
}
function Initialize-ShopdbBackup {
<#
Every check that can say "nothing to do", in one call.
Returns a context object. .Proceed is $false when the run should stop -
the reason has already been logged, once. On $true the context carries
Kind, BaseUrl, CollectorKey, MachineNumber and IntervalHours, and the
marker has NOT yet been stamped (Send-ShopdbBackup does that).
#>
param([Parameter(Mandatory)][string]$Kind,
[string]$BaseUrl,
[scriptblock]$MachineNumberFallback,
[switch]$Force)
$ctx = [pscustomobject]@{
Kind = $Kind
Proceed = $false
BaseUrl = ''
CollectorKey = ''
MachineNumber = ''
IntervalHours = 24
}
$machineNumber = Get-ShopdbMachineNumber -Fallback $MachineNumberFallback
if (-not $machineNumber) {
Write-ShopdbQuietState -Kind $Kind -State 'no-machine-number' -Message (
'No machine number in pc-config.txt and no device fallback. ' +
'A backup cannot be filed against an asset - skipping.')
return $ctx
}
$collectorKey = Get-ShopdbCollectorKey
if (-not $collectorKey) {
Write-ShopdbQuietState -Kind $Kind -State 'no-collector-key' -Message (
"No collector key in $script:SHOPDBREG\CollectorKey or $script:KEYFILE. " +
'The collector endpoint has no IP-allowlist path, unlike the GE-Enforce ' +
'manifest fetch, so it always needs a collector-scoped token. Skipping.')
return $ctx
}
if (-not $BaseUrl) { $BaseUrl = Get-ShopdbRegValue -Name 'BaseUrl' }
if (-not $BaseUrl) {
Write-ShopdbQuietState -Kind $Kind -State 'no-base-url' -Message (
"No ShopDB BaseUrl in $script:SHOPDBREG and none passed. Skipping.")
return $ctx
}
$BaseUrl = $BaseUrl.TrimEnd('/')
$intervalHours = Get-ShopdbIntervalHours -Kind $Kind -BaseUrl $BaseUrl
$markerfile = Join-Path $script:LOGDIR ('{0}-backup.marker' -f $Kind)
if (-not $Force -and (Test-Path $markerfile)) {
try {
$last = (Get-Item $markerfile).LastWriteTime
if (((Get-Date) - $last).TotalHours -lt $intervalHours) {
$due = $last.AddHours($intervalHours).ToString('yyyy-MM-dd HH:mm')
Write-ShopdbQuietState -Kind $Kind -State ("throttled-$due") -Message (
"Throttled: posted within the last ${intervalHours}h, next attempt after $due. " +
'Use -Force to override.')
return $ctx
}
} catch { }
}
$ctx.Proceed = $true
$ctx.BaseUrl = $BaseUrl
$ctx.CollectorKey = $collectorKey
$ctx.MachineNumber = $machineNumber
$ctx.IntervalHours = $intervalHours
Clear-ShopdbQuietState -Kind $Kind
Write-ShopdbBackupLog -Kind $Kind -Message (
"=== $Kind backup start === machine $machineNumber, $BaseUrl, interval ${intervalHours}h")
return $ctx
}
function Send-ShopdbBackup {
<#
POST one captured config to the ShopDB collector.
-Bytes for a kind ShopDB parses and stores (storagebackend 'shopdb').
-ContentHash with -SharePath for a kind whose bytes stay on the share
(storagebackend 'share'), where ShopDB keeps metadata and a pointer.
Returns $true when ShopDB accepted it, whether that produced a new
revision or a no-op; an unchanged config is a no-op by design and is
the expected outcome most cycles.
#>
param([Parameter(Mandatory)][pscustomobject]$Context,
[byte[]]$Bytes,
[string]$ContentHash,
[string]$SharePath,
[string]$SourceFileName,
[int]$TimeoutSec = 30)
$kind = $Context.Kind
# Marker BEFORE the post, deliberately. If ShopDB is unreachable we do not
# want every cycle for the rest of the day retrying; the next window picks
# it up.
$markerfile = Join-Path $script:LOGDIR ('{0}-backup.marker' -f $kind)
Set-Content -Path $markerfile -Value (Get-Date -Format 'o') -EA SilentlyContinue
# sourcehostname is load-bearing now, not just informational: ShopDB
# resolves a part-marker PC's backup to ITS marker through this field, and
# dedup keys a revision chain on it. An empty value silently files the
# backup against the operation instead and merges two devices' histories,
# so fall back to the DNS name rather than posting a blank.
$sourcehost = $env:COMPUTERNAME
if (-not $sourcehost) {
try { $sourcehost = [System.Net.Dns]::GetHostName() } catch { $sourcehost = '' }
}
$payload = @{
machinenumber = $Context.MachineNumber
backupkind = $kind
sourcehostname = $sourcehost
collectedat = (Get-Date).ToUniversalTime().ToString('o')
}
if ($SourceFileName) { $payload['sourcefilename'] = $SourceFileName }
if ($Bytes) {
$payload['contentbase64'] = [Convert]::ToBase64String($Bytes)
$payload['bytesize'] = $Bytes.Length
}
if ($ContentHash) { $payload['contenthash'] = $ContentHash }
if ($SharePath) { $payload['sharepath'] = $SharePath }
$uri = '{0}/api/collector/backups' -f $Context.BaseUrl
$size = if ($Bytes) { '{0} bytes' -f $Bytes.Length } else { $SharePath }
Write-ShopdbBackupLog -Kind $kind -Message (
'Posting {0} for machine {1}' -f $size, $Context.MachineNumber)
try {
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$resp = Invoke-RestMethod -Uri $uri -Method Post `
-Body ($payload | ConvertTo-Json -Compress) `
-ContentType 'application/json' `
-Headers @{ 'X-API-Key' = $Context.CollectorKey } `
-TimeoutSec $TimeoutSec -EA Stop
switch ("$($resp.data.action)") {
'created' { Write-ShopdbBackupLog -Kind $kind -Message (
'New revision {0} recorded.' -f $resp.data.backuprevisionid) }
'noop' { Write-ShopdbBackupLog -Kind $kind -Message 'Settings unchanged - no new revision (expected most cycles).' }
default { Write-ShopdbBackupLog -Kind $kind -Message (
"ShopDB returned action '{0}'." -f $resp.data.action) }
}
foreach ($warning in @($resp.data.warnings)) {
if ($warning) { Write-ShopdbBackupLog -Kind $kind -Message " WARNING: $warning" }
}
return $true
} catch {
# A 400 here is usually meaningful rather than transient: an
# unconfigured device, or a machine number ShopDB does not know. Log the
# server's own message so the cause is visible at the bay.
$detail = $_.Exception.Message
try {
$stream = $_.Exception.Response.GetResponseStream()
$reader = New-Object IO.StreamReader($stream)
$body = $reader.ReadToEnd()
if ($body) { $detail = $body }
} catch { }
Write-ShopdbBackupLog -Kind $kind -Message "Post failed: $detail"
return $false
}
}
Export-ModuleMember -Function Initialize-ShopdbBackup, Send-ShopdbBackup,
Write-ShopdbBackupLog, Write-ShopdbQuietState, Clear-ShopdbQuietState,
Get-ShopdbRegValue, Get-ShopdbCollectorKey, Get-ShopdbMachineNumber,
Get-ShopdbIntervalHours, Get-ShopdbLogPath

View File

@@ -0,0 +1,203 @@
# Backup-NtlarsSettings.ps1
#
# Captures this PC's LIVE NTLARS/DNC registry settings and posts them to ShopDB,
# which files them against the MACHINE (not this PC) and keeps a revision
# history. A tech can then re-download the .reg from the machine's page instead
# of hunting for a per-machine file on the share.
#
# Runs from the SFLD share every GE-Enforce cycle as a Type=PS1 manifest entry
# with DetectionMethod=Always. Updating this file on the share changes fleet
# behaviour on the next cycle - there is no local copy to heal.
#
# WHY WOW6432Node IS EXPLICIT:
# NTLARS is a 32-bit app, so its settings physically live under
# HKLM:\SOFTWARE\WOW6432Node\GE Aircraft Engines\DNC. GE-Enforce runs this
# script in 64-bit PowerShell, where HKLM:\SOFTWARE\GE Aircraft Engines\DNC
# is a DIFFERENT (usually absent) key. Reading the unredirected path would
# find nothing and back up an empty config - silently, every cycle. The path
# below is therefore spelled out and never abbreviated.
#
# Note the asymmetry with what NTLARS itself writes: its Save... button
# exports WITHOUT the WOW6432Node segment. ShopDB accepts either dialect and
# stores a dialect-neutral projection, so this script does not need to care.
#
# THROTTLE, LOGGING, CREDENTIALS:
# All handled by common\lib\ShopdbBackupClient.psm1, which every backup kind
# shares. GE-Enforce fires this every cycle, so the module holds the marker
# file, exits early until backups_intervalhours has elapsed, and logs a
# no-change outcome ONCE rather than every five minutes. The interval comes
# from ShopDB, so cadence is changed centrally rather than by editing this
# file on the share.
#
# Always exits 0 so the GE-Enforce "last run result" stays clean. Failures are
# logged, never thrown.
param(
[string]$BaseUrl = 'https://tsgwp00525.wjs.geaerospace.net/shopdb',
[int]$TimeoutSec = 30,
# Force a post regardless of the throttle. For a tech capturing a
# known-good config on demand.
[switch]$Force
)
$ErrorActionPreference = 'Continue'
# The only path this script still owns. Log, marker, state, collector key,
# enrollment and base URL all moved to ShopdbBackupClient, which names its files
# per KIND so two backup kinds on one bay cannot collide.
$DNCKEY = 'HKLM:\SOFTWARE\WOW6432Node\GE Aircraft Engines\DNC'
function Convert-RegTypeName {
param($Kind)
switch ("$Kind") {
'String' { 'REG_SZ' }
'ExpandString' { 'REG_EXPAND_SZ' }
'DWord' { 'REG_DWORD' }
'QWord' { 'REG_QWORD' }
'MultiString' { 'REG_MULTI_SZ' }
'Binary' { 'REG_BINARY' }
default { 'REG_SZ' }
}
}
function Get-DncKeys {
<#
The DNC key and every subkey, root first. Split out from
Export-DncToReg so the formatting logic can be exercised against mock
keys on a machine with no registry (see Test-RegExport.ps1).
#>
$keys = @(Get-Item -Path $DNCKEY -EA Stop)
$keys += @(Get-ChildItem -Path $DNCKEY -Recurse -EA SilentlyContinue)
return $keys
}
function Export-DncToReg {
<#
Emits .reg text in the WOW6432Node dialect for the supplied keys.
Built by hand rather than shelling out to `reg export` because reg.exe
writes UTF-16 to a temp file we would then have to read back, and
because this keeps the value types explicit instead of reparsing them.
Takes the key list as a parameter so it can be tested with mocks; the
escaping and dword formatting here are the part that would corrupt a
backup silently and only surface at restore time.
#>
param([Parameter(Mandatory)]$Keys)
$lines = @('Windows Registry Editor Version 5.00', '')
$lines += "; NTLARS DNC Registry Backup"
$lines += "; Computer: $env:COMPUTERNAME"
$lines += "; Date: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
$lines += ''
foreach ($key in $Keys) {
# PSPath -> the literal HKEY_LOCAL_MACHINE\... form a .reg file needs.
$path = $key.Name -replace '^HKEY_LOCAL_MACHINE', 'HKEY_LOCAL_MACHINE'
$lines += "[$path]"
foreach ($name in $key.GetValueNames()) {
$kind = Convert-RegTypeName $key.GetValueKind($name)
$data = $key.GetValue($name)
$lhs = if ($name -eq '') { '@' } else {
# .NET replacement strings do NOT process backslash escapes, so
# the replacement is the literal output: '\\' emits two
# backslashes, which is what .reg escaping wants. Writing
# '\\\\' here emits FOUR and silently corrupts every path-valued
# setting - verified on Windows before this was fixed.
'"{0}"' -f ($name -replace '\\', '\\' -replace '"', '\"')
}
switch ($kind) {
'REG_DWORD' {
$lines += ('{0}=dword:{1:x8}' -f $lhs, [uint32]$data)
}
'REG_BINARY' {
$hex = ($data | ForEach-Object { '{0:x2}' -f $_ }) -join ','
$lines += ('{0}=hex:{1}' -f $lhs, $hex)
}
'REG_QWORD' {
$bytes = [BitConverter]::GetBytes([uint64]$data)
$hex = ($bytes | ForEach-Object { '{0:x2}' -f $_ }) -join ','
$lines += ('{0}=hex(b):{1}' -f $lhs, $hex)
}
'REG_MULTI_SZ' {
$joined = (($data -join "`0") + "`0`0")
$bytes = [Text.Encoding]::Unicode.GetBytes($joined)
$hex = ($bytes | ForEach-Object { '{0:x2}' -f $_ }) -join ','
$lines += ('{0}=hex(7):{1}' -f $lhs, $hex)
}
'REG_EXPAND_SZ' {
$bytes = [Text.Encoding]::Unicode.GetBytes(("$data" + "`0"))
$hex = ($bytes | ForEach-Object { '{0:x2}' -f $_ }) -join ','
$lines += ('{0}=hex(2):{1}' -f $lhs, $hex)
}
default {
$escaped = ("$data" -replace '\\', '\\' -replace '"', '\"')
$lines += ('{0}="{1}"' -f $lhs, $escaped)
}
}
}
$lines += ''
}
return ($lines -join "`r`n") + "`r`n"
}
# =============================================================================
# Main
#
# Guarded so the file can be DOT-SOURCED to get the functions without running a
# backup. Test-RegExport.ps1 relies on this to exercise Export-DncToReg against
# mock keys on a machine with no registry.
# =============================================================================
if ($MyInvocation.InvocationName -eq '.') { return }
# The plumbing - key lookup, base URL, interval, throttle, logging, the POST and
# its response handling - lives in ShopdbBackupClient. It used to live here, and
# being the only implementation meant every defect in it was invisible: the
# interval read fell back to a hardcoded 24h for months because nobody had a
# second copy to compare against, and the log wrote three lines every five
# minutes whatever happened. Seven other Backup-*.ps1 scripts on this share
# capture device configs and post none of them; they can now do so without
# inheriting a copy of all that.
#
# What stays here is the part that is actually about NTLARS: where its registry
# lives, and how to turn it into .reg text.
Import-Module (Join-Path $PSScriptRoot '..\lib\ShopdbBackupClient.psm1') -Force
if (-not (Test-Path $DNCKEY)) {
# Not an error: plenty of PC types have no NTLARS at all. On those PCs this
# is the permanent state, so it is said once rather than 288 times a day.
Write-ShopdbQuietState -Kind 'ntlars' -State 'no-dnc-key' -Message (
"No DNC key at $DNCKEY - NTLARS is not installed on this PC. Nothing to do.")
exit 0
}
# NTLARS keeps its own MachineNo, used only when pc-config.txt has none. ShopDB
# compares the two and warns on a mismatch rather than silently trusting one.
$ctx = Initialize-ShopdbBackup -Kind 'ntlars' -Force:$Force `
-BaseUrl $(if ($PSBoundParameters.ContainsKey('BaseUrl')) { $BaseUrl } else { '' }) `
-MachineNumberFallback {
try {
$general = Get-ItemProperty -Path (Join-Path $DNCKEY 'General') -EA Stop
if ($general.MachineNo) { return ([string]$general.MachineNo).Trim() }
} catch { }
return ''
}
if (-not $ctx.Proceed) { exit 0 }
try {
$regText = Export-DncToReg -Keys (Get-DncKeys)
} catch {
Write-ShopdbBackupLog -Kind 'ntlars' -Message "Failed to read the DNC key: $_"
exit 0
}
# UTF-16LE + BOM, matching what regedit and NTLARS emit. ShopDB sniffs the BOM,
# so this is belt-and-braces rather than strictly required.
$bytes = [byte[]](0xFF, 0xFE) + [Text.Encoding]::Unicode.GetBytes($regText)
[void](Send-ShopdbBackup -Context $ctx -Bytes $bytes `
-SourceFileName ("{0}.reg" -f $ctx.MachineNumber) -TimeoutSec $TimeoutSec)
Write-ShopdbBackupLog -Kind 'ntlars' -Message '=== Backup-NtlarsSettings end ==='
exit 0

View File

@@ -0,0 +1,33 @@
@echo off
REM Install-AcroReader.cmd - Install Adobe Acrobat Reader DC + DC update patch
REM
REM Two-step install: base MSI with enterprise transform, then DC update patch.
REM Script lives in <type>/scripts/; MSI + MST + MSP + CAB live in sibling
REM <type>/apps/. pushd into apps\ so the MSI's Media-table CAB reference
REM (Data1.cab) resolves against its sibling, and msiexec doesn't choke on
REM a mapped-drive path with ..\ normalization (was returning 1619).
setlocal
pushd "%~dp0..\apps"
echo Installing Adobe Acrobat Reader DC...
msiexec /i "AcroRead.msi" TRANSFORMS="AcroRead.mst" /quiet /norestart
set RC=%errorlevel%
if %RC% neq 0 if %RC% neq 3010 (
echo Acrobat Reader MSI failed with exit code %RC%
popd
exit /b %RC%
)
echo Applying Adobe Reader DC update patch...
msiexec /p "AcroRdrDCUpd2500120531.msp" /quiet /norestart
set RC=%errorlevel%
if %RC% neq 0 if %RC% neq 3010 (
echo Acrobat Reader patch failed with exit code %RC%
popd
exit /b %RC%
)
popd
echo Adobe Acrobat Reader DC installed successfully.
exit /b 0

View File

@@ -0,0 +1,75 @@
@echo off
REM Install-Oracle11r2.cmd
REM Expands the GE Oracle Client 11.2 Administrator zip to a temp dir and
REM runs Oracle Universal Installer silently with the GE-customized
REM response file.
REM
REM Expected layout on the SFLD share (relative to this .cmd):
REM ..\apps\Oracle_OracleDatabase_11r2_V03.zip (686 MB)
REM
REM Called by Install-FromManifest.ps1 (Type=CMD). Exit codes surface back
REM to the enforcer.
REM
REM Oracle 11.2 OUI exit codes worth knowing:
REM 0 = success
REM 3 = success but with warnings
REM 1 = general failure
REM 6 = silent install requested but missing / bad response file
setlocal enabledelayedexpansion
set "LOG=C:\Logs\OracleClient\install.log"
if not exist "C:\Logs\OracleClient" mkdir "C:\Logs\OracleClient"
REM Emit a datestamp
for /f "tokens=2 delims==" %%I in ('wmic os get localdatetime /value 2^>nul ^| find "="') do set LDT=%%I
set "STAMP=!LDT:~0,14!"
echo [%STAMP%] Install-Oracle11r2.cmd starting >> "%LOG%"
set "SRC_ZIP=%~dp0..\apps\Oracle_OracleDatabase_11r2_V03.zip"
set "STAGING=%TEMP%\oracle-11r2-install"
set "CLIENT_DIR=%STAGING%\Oracle_OracleDatabase_11r2_V03\client"
set "RSP=%CLIENT_DIR%\response\ge_client_install.rsp"
if not exist "%SRC_ZIP%" (
echo [%STAMP%] ERROR: zip not found at %SRC_ZIP% >> "%LOG%"
echo ERROR: zip not found at %SRC_ZIP%
exit /b 2
)
echo [%STAMP%] Expanding %SRC_ZIP% to %STAGING% >> "%LOG%"
if exist "%STAGING%" rmdir /s /q "%STAGING%" >nul 2>&1
mkdir "%STAGING%"
powershell.exe -NoProfile -ExecutionPolicy Bypass -Command ^
"try { Expand-Archive -Path '%SRC_ZIP%' -DestinationPath '%STAGING%' -Force -ErrorAction Stop; exit 0 } catch { Write-Error $_; exit 1 }" ^
>> "%LOG%" 2>&1
if not exist "%CLIENT_DIR%\setup.exe" (
echo [%STAMP%] ERROR: expanded setup.exe not found at %CLIENT_DIR%\setup.exe >> "%LOG%"
exit /b 3
)
if not exist "%RSP%" (
echo [%STAMP%] ERROR: response file missing at %RSP% >> "%LOG%"
exit /b 4
)
echo [%STAMP%] Running OUI silent install (this takes 2-8 minutes) >> "%LOG%"
"%CLIENT_DIR%\setup.exe" -silent -waitforcompletion -nowait ^
-ignoreSysPrereqs ^
-responseFile "%RSP%" >> "%LOG%" 2>&1
set RC=%ERRORLEVEL%
echo [%STAMP%] OUI exit code: %RC% >> "%LOG%"
REM Cleanup staging dir to reclaim ~1.5 GB - OUI copies everything to ORACLE_HOME
echo [%STAMP%] Cleaning up staging dir >> "%LOG%"
rmdir /s /q "%STAGING%" >nul 2>&1
REM OUI returns 0 for success, 3 for success-with-warnings. Treat both as OK.
if %RC%==3 (
echo [%STAMP%] OUI reported warnings but install succeeded - returning 0 >> "%LOG%"
exit /b 0
)
exit /b %RC%

View File

@@ -0,0 +1,62 @@
# Migrate-PCType.ps1 - One-shot in-place rename of legacy pc-type.txt
# values to the gea-shopfloor-* taxonomy.
#
# Idempotent + safe: no-op if pc-type.txt already starts with
# 'gea-shopfloor-'. Decides collections vs nocollections from UDC's
# Uninstall reg presence (collections has UDC, nocollections doesn't).
# Standard-Timeclock + Lab map to gea-shopfloor-common.
#
# Runs every cycle (DetectionMethod=Always in manifest). Cheap because
# the no-op fast path is just one Get-Content + StartsWith check.
$ErrorActionPreference = 'Continue'
$typeFile = 'C:\Enrollment\pc-type.txt'
$subTypeFile = 'C:\Enrollment\pc-subtype.txt'
if (-not (Test-Path $typeFile)) { exit 0 }
$current = (Get-Content -LiteralPath $typeFile -First 1 -ErrorAction SilentlyContinue).Trim()
if (-not $current) { exit 0 }
# Already on new taxonomy
if ($current.StartsWith('gea-shopfloor-')) { exit 0 }
$subType = ''
if (Test-Path $subTypeFile) {
$subType = (Get-Content -LiteralPath $subTypeFile -First 1 -ErrorAction SilentlyContinue).Trim()
}
# Map legacy -> new
$newType = $null
switch -Regex ($current) {
'^Standard$' {
if ($subType -ieq 'Machine') {
$udcReg = Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\UDC'
$newType = if ($udcReg) { 'gea-shopfloor-collections' } else { 'gea-shopfloor-nocollections' }
} elseif ($subType -ieq 'Timeclock') {
$newType = 'gea-shopfloor-common'
} else {
# Standard with no subtype - default to collections (most common)
$newType = 'gea-shopfloor-collections'
}
}
'^CMM$' { $newType = 'gea-shopfloor-cmm' }
'^Keyence$' { $newType = 'gea-shopfloor-keyence' }
'^Lab$' { $newType = 'gea-shopfloor-common' }
'^WaxAndTrace$' { $newType = 'gea-shopfloor-waxtrace' }
'^Genspect$' { $newType = 'gea-shopfloor-genspect' }
'^Display$' { $newType = 'gea-shopfloor-display' }
'^Heattreat$' { $newType = 'gea-shopfloor-heattreat' }
default { Write-Host "Migrate-PCType: unmapped legacy value '$current' - leaving alone"; exit 0 }
}
Write-Host "Migrate-PCType: '$current' (subType='$subType') -> '$newType'"
Set-Content -LiteralPath $typeFile -Value $newType -Encoding ascii -ErrorAction Stop
# Drop pc-subtype.txt - new taxonomy is single-string
if (Test-Path $subTypeFile) {
try { Remove-Item -LiteralPath $subTypeFile -Force -ErrorAction Stop; Write-Host " removed pc-subtype.txt" } catch {}
}
exit 0

View File

@@ -0,0 +1,68 @@
# Select-KioskType.ps1
#
# Imaging-time picker that records which kind of kiosk this PC is, so GE-Enforce
# (share or Flask API) enforces the right scope. Writes the scope name to
# C:\Enrollment\pc-type.txt - the same file every other pc-type reads, and the
# value the Flask client passes as -Scope.
#
# Three kiosk subtypes (split out of the old generic gea-shopfloor-display):
# 1 lobbydisplay -> gea-shopfloor-lobbydisplay (lobby TV, dt\tv\slides)
# 2 dashboard -> gea-shopfloor-dashboard (shopfloor dashboard)
# 3 printerkiosk -> gea-shopfloor-printerkiosk (3D-printer kiosk)
#
# Usage:
# interactive (imaging operator picks): .\Select-KioskType.ps1
# unattended (automation / task seq): .\Select-KioskType.ps1 -Type dashboard
# already-deployed kiosk (one-time set): .\Select-KioskType.ps1 -Type lobbydisplay
#
# Idempotent: rewrites pc-type.txt to the chosen scope. Always exits 0.
param(
[ValidateSet('lobbydisplay', 'dashboard', 'printerkiosk')]
[string]$Type,
[string]$EnrollmentFile = 'C:\Enrollment\pc-type.txt'
)
$ErrorActionPreference = 'Continue'
$map = [ordered]@{
'1' = @{ Key = 'lobbydisplay'; Scope = 'gea-shopfloor-lobbydisplay'; Desc = 'Lobby display (lobby TV / slides)' }
'2' = @{ Key = 'dashboard'; Scope = 'gea-shopfloor-dashboard'; Desc = 'Shopfloor dashboard' }
'3' = @{ Key = 'printerkiosk'; Scope = 'gea-shopfloor-printerkiosk'; Desc = '3D-printer kiosk' }
}
function Resolve-ScopeFromType([string]$t) {
foreach ($k in $map.Keys) { if ($map[$k].Key -eq $t) { return $map[$k].Scope } }
return $null
}
$scope = $null
if ($Type) {
$scope = Resolve-ScopeFromType $Type
} else {
Write-Host ''
Write-Host 'Select this PC kiosk type:' -ForegroundColor Cyan
foreach ($k in $map.Keys) { Write-Host (" {0}) {1}" -f $k, $map[$k].Desc) }
Write-Host ''
do {
$choice = Read-Host 'Enter 1, 2, or 3'
} while (-not $map.Contains($choice))
$scope = $map[$choice].Scope
}
if (-not $scope) {
Write-Host "ERROR could not resolve a kiosk scope (Type='$Type')." -ForegroundColor Red
exit 0
}
try {
$dir = Split-Path -Parent $EnrollmentFile
if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
$scope | Set-Content -NoNewline -LiteralPath $EnrollmentFile -ErrorAction Stop
Write-Host "wrote $EnrollmentFile = $scope" -ForegroundColor Green
} catch {
Write-Host "ERROR writing ${EnrollmentFile}: $($_.Exception.Message)" -ForegroundColor Red
}
exit 0

View File

@@ -0,0 +1,54 @@
# Set-EventSaverDisable.ps1
#
# Turn the EventSaver shopfloor screensaver OFF fleet-wide and undo its
# side effects. Runs under GE-Enforce (SYSTEM) every cycle. Idempotent +
# silent (SYSTEM context - no window).
#
# Self-excludes the canary test host: pass -ExceptHost <name> and this script
# does nothing on that PC, so the (canary-gated) enable entries keep it on
# there while every other shopfloor PC is cleaned.
#
# Undoes: screensaver registry (per-user), the EventSaver-Enable fallback
# task, the never-off power policy, and the staged .scr/.ini.
[CmdletBinding()]
param(
[string]$ExceptHost = '',
[string]$ScrPath = 'C:\Windows\System32\EventSaver.scr',
[string]$TaskName = 'EventSaver-Enable',
[int] $MonitorMinutes = 15, # restore a sane monitor-off (was Never)
[int] $StandbyMinutes = 20 # restore a sane sleep (was Never)
)
$ErrorActionPreference = 'Continue'
# leave the canary host alone - the enable entries own it there
if ($ExceptHost -and ($env:COMPUTERNAME -ieq $ExceptHost)) { exit 0 }
function Disable-Saver($deskKey) {
if (Test-Path $deskKey) {
Set-ItemProperty -Path $deskKey -Name 'ScreenSaveActive' -Value '0' -Type String -Force -ErrorAction SilentlyContinue
Remove-ItemProperty -Path $deskKey -Name 'SCRNSAVE.EXE' -ErrorAction SilentlyContinue
}
}
# 1. screensaver off in the default profile + every loaded user hive
Disable-Saver 'Registry::HKEY_USERS\.DEFAULT\Control Panel\Desktop'
Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue |
Where-Object { $_.PSChildName -match '^S-1-5-21' -and $_.PSChildName -notmatch '_Classes$' } |
ForEach-Object { Disable-Saver "Registry::HKEY_USERS\$($_.PSChildName)\Control Panel\Desktop" }
# 2. remove the self-clearing fallback task if present
if (Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue) {
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue
}
# 3. restore power (undo the EventSaver never-off)
& powercfg /change monitor-timeout-ac $MonitorMinutes 2>&1 | Out-Null
& powercfg /change standby-timeout-ac $StandbyMinutes 2>&1 | Out-Null
# 4. remove staged binary + config (harmless if already gone)
Remove-Item -LiteralPath $ScrPath -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath ($ScrPath -replace '\.scr$', '.ini') -Force -ErrorAction SilentlyContinue
exit 0

View File

@@ -0,0 +1,29 @@
# Set-EventSaverPower.ps1
#
# Keep shopfloor monitors + PCs awake so the EventSaver ad screensaver is
# actually visible. Runs under GE-Enforce (SYSTEM) every cycle (Always).
# Sets monitor-off + sleep to Never on the ACTIVE power scheme, so a screen
# never blanks out from under the screensaver. Re-applies each cycle, so a
# power-plan change is corrected on the next enforce pass.
#
# Screensaver must trigger before any monitor-off would - with monitor-off
# set to Never here, the screensaver (2-10 min idle) always wins.
$ErrorActionPreference = 'Continue'
$logDir = 'C:\Logs\Shopfloor'
if (-not (Test-Path $logDir)) { New-Item -ItemType Directory -Path $logDir -Force | Out-Null }
$log = Join-Path $logDir 'eventsaver.log'
function Write-Log($m) {
Add-Content -LiteralPath $log -Value ("{0} {1}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $m)
}
# 0 = Never. Cover monitor + standby, AC + DC (shopfloor PCs are AC, DC is
# harmless belt-and-suspenders).
& powercfg /change monitor-timeout-ac 0 2>&1 | Out-Null
& powercfg /change monitor-timeout-dc 0 2>&1 | Out-Null
& powercfg /change standby-timeout-ac 0 2>&1 | Out-Null
& powercfg /change standby-timeout-dc 0 2>&1 | Out-Null
Write-Log "power: monitor-off + sleep set to Never (AC+DC) for EventSaver visibility"
exit 0

View File

@@ -0,0 +1,123 @@
# Set-EventSaverScreensaver.ps1
#
# Enable the EventSaver shopfloor screensaver for EVERY user on the box. Runs
# under GE-Enforce (SYSTEM) every cycle.
#
# WHY THIS WAS REWRITTEN (2026-08-06)
# The previous version wrote the timeout to exactly two places: HKU\.DEFAULT,
# which only seeds profiles created AFTERWARDS, and the hive of one profile
# whose folder had to be named literally 'Shopfloor'. On any PC where the
# operator signs in as anything else, no HKCU was ever touched - the screensaver
# then ran on whatever the profile already carried (a domain default, commonly
# 120 seconds), while GE-Enforce reported success every cycle because from its
# point of view it had done its job.
#
# That is what the "screensaver after 2 minutes instead of 9" reports were: the
# manifest said 480 and the machines had never been told.
#
# Now: seed .DEFAULT for future profiles, then apply to EVERY loaded user hive.
# Same approach Set-DisplayAlwaysOn.ps1 already uses for the kiosks. A user who
# signs in between cycles is picked up on the next one.
#
# IDEMPOTENT + SILENT: hives already correct are skipped, so the common path
# writes nothing.
[CmdletBinding()]
param(
[string]$ScrPath = 'C:\Windows\System32\EventSaver.scr',
[int] $TimeoutSeconds = 540,
# Retired. Kept so an older manifest passing -TargetUser does not fail to
# bind; it is deliberately ignored - targeting one named account is the bug
# this rewrite removes.
[string]$TargetUser = '',
[string]$TaskName = 'EventSaver-Enable'
)
$ErrorActionPreference = 'Continue'
$logDir = 'C:\Logs\Shopfloor'
if (-not (Test-Path $logDir)) { New-Item -ItemType Directory -Path $logDir -Force | Out-Null }
$log = Join-Path $logDir 'eventsaver.log'
function Write-Log($m) {
Add-Content -LiteralPath $log -Value ("{0} {1}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $m)
}
function Get-Val($deskKey, $name) {
return (Get-ItemProperty -Path $deskKey -Name $name -ErrorAction SilentlyContinue).$name
}
# Already what we want? Idempotency gate - keeps the common path silent.
function Saver-IsSet($deskKey) {
if (-not (Test-Path $deskKey)) { return $false }
if ((Get-Val $deskKey 'ScreenSaveActive') -ne '1') { return $false }
if ((Get-Val $deskKey 'SCRNSAVE.EXE') -ne $ScrPath) { return $false }
if ("$((Get-Val $deskKey 'ScreenSaveTimeOut'))" -ne "$TimeoutSeconds") { return $false }
return $true
}
function Set-SaverValues($deskKey) {
if (-not (Test-Path $deskKey)) { New-Item -Path $deskKey -Force | Out-Null }
Set-ItemProperty -Path $deskKey -Name 'ScreenSaveActive' -Value '1' -Type String -Force
Set-ItemProperty -Path $deskKey -Name 'SCRNSAVE.EXE' -Value $ScrPath -Type String -Force
Set-ItemProperty -Path $deskKey -Name 'ScreenSaveTimeOut' -Value "$TimeoutSeconds" -Type String -Force
Set-ItemProperty -Path $deskKey -Name 'ScreenSaverIsSecure' -Value '0' -Type String -Force
}
# --- 1. seed .DEFAULT so profiles created later start correct -----------------
$defKey = 'Registry::HKEY_USERS\.DEFAULT\Control Panel\Desktop'
if (-not (Saver-IsSet $defKey)) {
try { Set-SaverValues $defKey; Write-Log "seeded .DEFAULT ($TimeoutSeconds s)" }
catch { Write-Log "ERROR seeding .DEFAULT: $_" }
}
# --- 2. apply to every loaded human hive --------------------------------------
# Skipped: the three service accounts (SYSTEM, LOCAL SERVICE, NETWORK SERVICE)
# and the _Classes companions, which are not user desktops and would just add
# noise. Everything else that is loaded belongs to somebody signed in now.
$serviceSids = @('S-1-5-18', 'S-1-5-19', 'S-1-5-20')
$applied = 0
$already = 0
try {
$hives = Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction Stop | ForEach-Object { $_.PSChildName }
} catch {
Write-Log "ERROR enumerating HKEY_USERS: $_"
$hives = @()
}
foreach ($sid in $hives) {
if ($sid -eq '.DEFAULT') { continue } # handled above
if ($sid -like '*_Classes') { continue }
if ($serviceSids -contains $sid) { continue }
if ($sid -notlike 'S-1-5-21-*') { continue } # real domain/local users only
$hiveKey = "Registry::HKEY_USERS\$sid\Control Panel\Desktop"
if (Saver-IsSet $hiveKey) { $already++; continue }
try {
Set-SaverValues $hiveKey
$applied++
Write-Log "applied to hive $sid ($TimeoutSeconds s)"
} catch {
Write-Log "ERROR writing hive ${sid}: $_"
}
}
if ($applied -eq 0 -and $already -eq 0) {
# Nobody signed in - normal during imaging or on an idle bay. .DEFAULT above
# covers the next profile, and the next cycle after a logon covers the rest.
Write-Log 'no user hives loaded; .DEFAULT seeded, will apply on a later cycle'
}
# --- 3. remove the old per-user fallback task ---------------------------------
# The previous version registered an AtLogon task for the hardcoded 'Shopfloor'
# account. On machines where nobody signs in as that, it sat queued forever and
# never fired. Applying to loaded hives every cycle replaces it, so clear any
# that are still registered.
try {
if (Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue) {
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue
Write-Log "removed stale fallback task '$TaskName'"
}
} catch { }
exit 0

View File

@@ -0,0 +1,46 @@
# Set-FmsHostsEntry.ps1 - idempotently pin FMS host in C:\Windows\System32\drivers\etc\hosts.
#
# Why pinned:
# eDNC's FMS prescan (CPreScan::Initialise_Sockets in DncMain.exe and
# CDoPersonnel::InitializeSocket in DNCdll.dll) resolves the FMS host
# via MFC CSocket, which calls inet_addr first then gethostbyname. The
# legacy WinSock1 resolver path fails on the GE corporate network for
# wjfms3.ae.ge.com (modern getaddrinfo path used by PowerShell works
# fine, but eDNC does not use it). Hosts file entry is consulted by
# gethostbyname before any DNS query, so the pin short-circuits the
# broken legacy path.
#
# Idempotent: adds line if missing, leaves it alone if already present.
# Safe to run every cycle (DetectionMethod=Always in manifest).
$ErrorActionPreference = 'Stop'
$hostsPath = Join-Path $env:windir 'System32\drivers\etc\hosts'
$ip = '10.233.112.158'
$fqdn = 'WJFMS3.AE.GE.COM'
$line = "$ip`t$fqdn"
if (-not (Test-Path $hostsPath)) {
Write-Host "hosts file not found at $hostsPath - aborting"
exit 1
}
$content = Get-Content -LiteralPath $hostsPath -ErrorAction Stop
# Match any non-comment line that maps either the IP or the FQDN.
# Drops stale or wrong mappings of the same FQDN/IP, then appends the canonical pin.
$pattern = '(?i)^\s*[^#\s]+\s+\S*' + [regex]::Escape($fqdn) + '\b|^\s*' + [regex]::Escape($ip) + '\s'
$existing = $content | Where-Object { $_ -match $pattern }
$canonical = ($existing | Where-Object { $_ -match "^\s*$([regex]::Escape($ip))\s+$([regex]::Escape($fqdn))\s*$" })
if ($canonical -and $existing.Count -eq @($canonical).Count) {
# Already pinned correctly. No change.
exit 0
}
# Either no entry, or an entry exists with wrong IP/FQDN/casing/whitespace. Rewrite.
$kept = $content | Where-Object { $_ -notmatch $pattern }
$new = @($kept) + $line
Set-Content -LiteralPath $hostsPath -Value $new -Encoding ascii -ErrorAction Stop
Write-Host "Wrote FMS hosts pin: $line"
exit 0

View File

@@ -0,0 +1,58 @@
# Set-OpenTextToolbar.ps1 - replace the HostExplorer default VT toolbar.
#
# Copies the toolbar shipped in this share over the local one at:
# C:\ProgramData\Hummingbird\Connectivity\15.00\Shared\HostExplorer\Toolbar\
#
# Runs from the common manifest with DetectionMethod Always, so it also
# self-heals if the file is changed locally.
#
# Deliberately does NOT bump the OpenText version. Setup-OpenText.ps1 skips
# unless HKLM:\SOFTWARE\GE\OpenText\Installed differs from version.txt, so a
# bump re-runs the whole thing - MSI plus the SP1 patch - on every OpenText
# machine over SMB, to ship a 565-byte file. This entry copies just the file.
$ErrorActionPreference = 'Continue'
$fileName = 'Default VT Toolbar.tbv'
$src = Join-Path $PSScriptRoot "..\apps\opentext\HostExplorer\Toolbar\$fileName"
$dstDir = 'C:\ProgramData\Hummingbird\Connectivity\15.00\Shared\HostExplorer\Toolbar'
$dst = Join-Path $dstDir $fileName
Write-Host '=== OpenText VT toolbar ==='
if (-not (Test-Path -LiteralPath $src)) {
Write-Host " source missing on share: $src - nothing to do."
return
}
# Gate on OpenText actually being installed. The Shared root only exists once
# HostExplorer content has been deployed, so its absence means this machine has
# no OpenText and we should not create a stray Hummingbird tree.
$sharedRoot = 'C:\ProgramData\Hummingbird\Connectivity\15.00\Shared'
if (-not (Test-Path -LiteralPath $sharedRoot)) {
Write-Host ' OpenText not installed on this PC - skipping.'
return
}
# Skip when identical, so an Always entry is not rewriting the file every cycle.
if (Test-Path -LiteralPath $dst) {
$sh = (Get-FileHash -LiteralPath $src -Algorithm SHA256).Hash
$dh = (Get-FileHash -LiteralPath $dst -Algorithm SHA256).Hash
if ($sh -eq $dh) {
Write-Host ' already current - no change.'
return
}
Write-Host ' local copy differs - replacing.'
} else {
Write-Host ' not present locally - installing.'
}
try {
if (-not (Test-Path -LiteralPath $dstDir)) {
New-Item -ItemType Directory -Path $dstDir -Force -ErrorAction Stop | Out-Null
}
Copy-Item -LiteralPath $src -Destination $dst -Force -ErrorAction Stop
Write-Host " replaced $dst"
} catch {
Write-Warning " failed to replace ${dst}: $_"
}

View File

@@ -0,0 +1,137 @@
# Set-ShopdbCollectorKey.ps1
#
# Delivers the ShopDB collector credential to every shopfloor PC by writing
# HKLM:\SOFTWARE\GE\ShopDB (BaseUrl + CollectorKey), the same contract the
# display kiosks already use. Anything that posts to /api/collector/* reads it
# from there - today Backup-NtlarsSettings.ps1, tomorrow whatever else reports.
#
# WHY A KEY IS NEEDED AT ALL:
# The GE-Enforce manifest/payload FETCH honours an IP allowlist, so a bay on a
# trusted subnet pulls config with no token. Collector INGEST does not: it
# accepts only a collector-scoped token. That asymmetry is deliberate - fetch
# reads config we already publish, ingest WRITES asset data, and an allowlist
# alone would let anything on the subnet post revisions.
#
# WHERE THE SECRET LIVES:
# NOT in manifest.json, and not in this script. It is read from a sibling file
# on the share (see $KEYCONFIG below), so:
# - the manifest stays free of secrets and safe to read/diff
# - rotating the token is replacing ONE file, not editing a manifest
# The file inherits the share's ACL, which grants file-level reads only to the
# SFLD user. Treat it as a secret: scope the token to collector.ingest ONLY,
# so a leak cannot read or mutate anything else.
#
# IDEMPOTENT: compares current registry values against desired and writes only
# on a difference, so DetectionMethod=Always costs one registry read per cycle
# after the first run. Rotating the key on the share re-converges the fleet on
# the next cycle with no other action.
#
# Always exits 0 so the GE-Enforce "last run result" stays clean.
param(
# Override for a one-off run; normally read from the sibling config file.
[string]$CollectorKey,
[string]$BaseUrl
)
$ErrorActionPreference = 'Continue'
$SHOPDBREG = 'HKLM:\SOFTWARE\GE\ShopDB'
$KEYCONFIG = Join-Path $PSScriptRoot '..\configs\shopdb-collector.txt'
$LOGDIR = 'C:\Logs\Shopfloor'
$LOGFILE = Join-Path $LOGDIR 'shopdb-collector-key.log'
if (-not (Test-Path $LOGDIR)) {
New-Item -ItemType Directory -Path $LOGDIR -Force -EA SilentlyContinue | Out-Null
}
function Log {
param([string]$Message)
$line = '[{0}] {1}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Message
Add-Content -Path $LOGFILE -Value $line -EA SilentlyContinue
Write-Host $line
}
function Read-KeyConfig {
<#
LABELLED lines only:
baseurl=https://host/shopdb
collector=<collector.ingest token>
A bare line is ignored rather than guessed at - a fetch token landing in
the collector slot would leave reporting broken while looking configured.
#>
$result = @{ BaseUrl = ''; CollectorKey = '' }
if (-not (Test-Path $KEYCONFIG)) { return $result }
foreach ($line in (Get-Content $KEYCONFIG -EA SilentlyContinue)) {
$t = $line.Trim()
if (-not $t -or $t.StartsWith('#')) { continue }
if ($t -match '^(?i)collector\s*=\s*(.+)$') { $result.CollectorKey = $Matches[1].Trim() }
elseif ($t -match '^(?i)baseurl\s*=\s*(.+)$') { $result.BaseUrl = $Matches[1].Trim().TrimEnd('/') }
else { Log " ignoring unlabelled line in $(Split-Path $KEYCONFIG -Leaf)" }
}
return $result
}
Log '=== Set-ShopdbCollectorKey start ==='
$cfg = Read-KeyConfig
if (-not $CollectorKey) { $CollectorKey = $cfg.CollectorKey }
if (-not $BaseUrl) { $BaseUrl = $cfg.BaseUrl }
if (-not $CollectorKey) {
# Not an error: an unconfigured share is the normal state before a site
# issues its token. Say exactly what to do rather than failing silently.
Log "No collector key configured. Put a 'collector=<token>' line in:"
Log " $KEYCONFIG"
Log "Nothing written."
exit 0
}
# --- compare before writing ------------------------------------------------
$current = $null
try { $current = Get-ItemProperty -Path $SHOPDBREG -EA Stop } catch { }
$needKey = (-not $current) -or ($current.CollectorKey -ne $CollectorKey)
$needUrl = $BaseUrl -and ((-not $current) -or ($current.BaseUrl -ne $BaseUrl))
if (-not $needKey -and -not $needUrl) {
Log 'Registry already matches - nothing to do.'
Log '=== Set-ShopdbCollectorKey end ==='
exit 0
}
try {
if (-not (Test-Path $SHOPDBREG)) { New-Item -Path $SHOPDBREG -Force | Out-Null }
if ($needUrl) {
New-ItemProperty -Path $SHOPDBREG -Name BaseUrl -Value $BaseUrl `
-PropertyType String -Force -EA Stop | Out-Null
Log "Set BaseUrl = $BaseUrl"
}
if ($needKey) {
New-ItemProperty -Path $SHOPDBREG -Name CollectorKey -Value $CollectorKey `
-PropertyType String -Force -EA Stop | Out-Null
Log "Set CollectorKey (length $($CollectorKey.Length)) - value not logged"
}
# Lock the key down to SYSTEM + Administrators, matching what
# Install-ShopdbKiosk does on displays. Without this the value is readable
# by any interactive user, and a shopfloor PC is a shared login.
try {
$acl = Get-Acl $SHOPDBREG
$acl.SetAccessRuleProtection($true, $false)
foreach ($who in 'SYSTEM', 'Administrators') {
$acl.AddAccessRule((New-Object Security.AccessControl.RegistryAccessRule(
$who, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow')))
}
Set-Acl -Path $SHOPDBREG -AclObject $acl -EA Stop
Log 'ACL set: SYSTEM + Administrators only.'
} catch {
Log "WARNING - could not tighten the ACL: $_"
}
} catch {
Log "FAILED to write ${SHOPDBREG}: $_"
}
Log '=== Set-ShopdbCollectorKey end ==='
exit 0

View File

@@ -0,0 +1,15 @@
@echo off
REM Setup-OpenText.cmd - launcher for Setup-OpenText.ps1.
REM
REM Lives in <type>/scripts/. Setup-OpenText.ps1 expects to find the four
REM OpenText binaries (OpenTextHostExplorer15x64.msi/.cab/.msp + ShopFloorx64.mst)
REM plus version.txt in $SourceDir. We pass ..\apps\opentext\ explicitly so the
REM script doesn't fall back to its $PSScriptRoot default (which would be the
REM scripts/ dir, where the binaries don't live).
REM
REM Mirrors the Install-AcroReader.cmd / Install-Oracle11r2.cmd pattern of
REM script-in-scripts, payload-in-apps. Called by Install-FromManifest.ps1
REM (Type=CMD); exit code surfaces back to the enforcer.
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0Setup-OpenText.ps1" -SourceDir "%~dp0..\apps\opentext"
exit /b %errorlevel%

View File

@@ -0,0 +1,361 @@
#!/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
}
# Normalize $SourceDir to a canonical absolute path. The CMD shim passes
# "%~dp0..\apps\opentext" which embeds a literal "..". msiexec / the Windows
# Installer service fail to open the package with that unresolved segment
# (exit 1619, ERROR_INSTALL_PACKAGE_OPEN_FAILED), even though every other
# .NET / PowerShell API resolves it fine. Resolve-Path collapses ".." into
# a clean drive-rooted path before any msiexec invocation.
if (Test-Path -LiteralPath $SourceDir) {
try { $SourceDir = (Resolve-Path -LiteralPath $SourceDir).ProviderPath } catch {}
}
# --- 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\
# <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..."
# --- 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

View File

@@ -0,0 +1,82 @@
# Test-RegExport.ps1
#
# Exercises Backup-NtlarsSettings.ps1's Export-DncToReg against MOCK registry
# keys, so the formatting can be verified on a machine with no registry (a
# Linux dev box running PowerShell Core, for instance).
#
# Why this exists: bad escaping or a wrong dword format produces a .reg that
# looks fine, imports without complaint, and only reveals itself when a tech
# restores a machine and the settings are subtly wrong. That failure is far too
# late and far too expensive, so the formatting gets tested away from the bay.
#
# Writes the generated .reg to -OutFile so the ShopDB-side Python codec can
# parse it in the same run and confirm both ends agree.
#
# pwsh -NoProfile -File Test-RegExport.ps1 -OutFile /tmp/mock.reg
param(
[string]$OutFile = './mock-export.reg'
)
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'Backup-NtlarsSettings.ps1')
function New-MockKey {
<#
A stand-in for a Microsoft.Win32.RegistryKey. PowerShell is duck-typed,
so Export-DncToReg only needs .Name, .GetValueNames(), .GetValueKind()
and .GetValue().
#>
param(
[Parameter(Mandatory)][string]$Name,
[Parameter(Mandatory)][hashtable]$Values # name -> @{ Kind; Data }
)
$key = [pscustomobject]@{ Name = $Name; _values = $Values }
$key | Add-Member ScriptMethod GetValueNames { $this._values.Keys } -Force
$key | Add-Member ScriptMethod GetValueKind {
param($n) $this._values[$n].Kind } -Force
$key | Add-Member ScriptMethod GetValue {
param($n) $this._values[$n].Data } -Force
return $key
}
$root = 'HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\GE Aircraft Engines\DNC'
$keys = @(
New-MockKey -Name $root -Values @{
'COMPUTERNAME' = @{ Kind = 'String'; Data = 'GGBX0NH3ESF' }
}
New-MockKey -Name "$root\General" -Values @{
'MachineNo' = @{ Kind = 'String'; Data = '3204' }
'Cnc' = @{ Kind = 'String'; Data = 'OKUMA' }
'HostType' = @{ Kind = 'String'; Data = 'WILM' }
}
New-MockKey -Name "$root\Btr" -Values @{
'BTR Rate' = @{ Kind = 'String'; Data = '300' }
'Auto Rewind' = @{ Kind = 'String'; Data = 'YES' }
'CmntLag' = @{ Kind = 'DWord'; Data = 0 }
'BigCount' = @{ Kind = 'DWord'; Data = 4294967295 }
}
# The nasty cases: characters that must be escaped, and the wide/binary
# value kinds that do not occur in the current corpus but are legal.
New-MockKey -Name "$root\EdgeCases" -Values @{
'Path With Backslash' = @{ Kind = 'String'; Data = 'C:\Program Files\GE' }
'Has "Quotes"' = @{ Kind = 'String'; Data = 'say "hi"' }
'Empty' = @{ Kind = 'String'; Data = '' }
'Expanded' = @{ Kind = 'ExpandString'; Data = '%SystemRoot%\dnc' }
'Multi' = @{ Kind = 'MultiString'; Data = @('one','two') }
'Blob' = @{ Kind = 'Binary'; Data = [byte[]](1,2,255) }
'Big' = @{ Kind = 'QWord'; Data = [uint64]1234567890123 }
}
)
$text = Export-DncToReg -Keys $keys
# UTF-16LE + BOM, matching what the real script posts.
$bytes = [byte[]](0xFF, 0xFE) + [Text.Encoding]::Unicode.GetBytes($text)
[IO.File]::WriteAllBytes($OutFile, $bytes)
Write-Host "Wrote $OutFile ($($bytes.Length) bytes)"
Write-Host '--- first lines ---'
($text -split "`r`n" | Select-Object -First 12) | ForEach-Object { Write-Host " $_" }

View File

@@ -0,0 +1,37 @@
# ensure-vnc-firewall.ps1
# Idempotent inbound firewall rules for VNC port 5900 on all network profiles.
# Called by Install-FromManifest with Type=PS1, DetectionMethod=Always (runs
# every enforcement cycle; the Remove + New pattern makes repeat runs cheap
# and always end in a known-good state).
#
# Exit 0 on success, 1 on failure. SYSTEM context.
$ErrorActionPreference = 'Continue'
$rules = @(
@{ Name = 'GE Shopfloor VNC 5900 TCP'; Protocol = 'TCP'; LocalPort = 5900 }
@{ Name = 'GE Shopfloor VNC 5900 UDP'; Protocol = 'UDP'; LocalPort = 5900 }
)
$failed = 0
foreach ($r in $rules) {
try {
Remove-NetFirewallRule -DisplayName $r.Name -ErrorAction SilentlyContinue
New-NetFirewallRule `
-DisplayName $r.Name `
-Direction Inbound `
-Protocol $r.Protocol `
-LocalPort $r.LocalPort `
-Action Allow `
-Profile Domain,Private,Public `
-Description 'VNC remote access for shopfloor ops (5900). Managed by GE Shopfloor Enforce.' `
-ErrorAction Stop | Out-Null
Write-Host "[OK] $($r.Name) ($($r.Protocol) $($r.LocalPort))"
} catch {
Write-Host "[FAIL] $($r.Name): $_"
$failed++
}
}
if ($failed -gt 0) { exit 1 }
exit 0