sweep: pre-existing drift + matrix UDC entry + ignore 142MB EXE

Bundles drift left uncommitted from prior sessions and the UDC matrix
verify entry added today.

Drift items (all per session-progress.md, completed in earlier sessions
but never staged):

- playbook/check-bios.cmd (deleted, moved to BIOS/check-bios.cmd)
- playbook/migrate-to-wifi.ps1 (made no-op 2026-04-24 after the dnsmasq
  no-gateway fix removed the wired-NIC race that motivated it)
- playbook/preinstall/oracle/Install-Oracle11r2.cmd (post-OUI .ora copy
  added 2026-04-24)
- playbook/preinstall/oracle/tnsnames.ora (live tnsnames, 469 KB,
  deployed alongside the wrapper 2026-04-24)
- playbook/pxe_server_setup.yml (dnsmasq dhcp-option=3,6 commented,
  Oracle .ora deploy task added 2026-04-24)
- playbook/shopfloor-setup/BIOS/{check-bios.cmd, models.txt} (BIOS
  detection refinements)
- playbook/shopfloor-setup/Shopfloor/Force-Lockdown.bat
- playbook/shopfloor-setup/Shopfloor/Monitor-IntuneProgress.ps1
- playbook/shopfloor-setup/Shopfloor/SetShopfloorAutoLogon.bat (new)
- playbook/shopfloor-setup/Shopfloor/09-Install-PrinterInstallerMap.ps1
  (new, places PrinterInstallerMap.exe + Public Desktop shortcut at
  imaging time; manifest entry self-heals on tamper)
- playbook/shopfloor-setup/Shopfloor/lib/Show-IntuneDeviceQR.ps1 (new,
  standalone QR rendering for site that wanted just that piece)
- playbook/shopfloor-setup/gea-shopfloor-collections/{Install-eMxInfo.cmd.template,
  Restore-UDCData.ps1} (these were uncommitted in pre-rename Standard/;
  git mv didn't catch them because they were untracked at the time)
- docs/shopfloor-machine-imaging-guide.md (operator-facing how-to)

Matrix:
- common.test/matrix.json: add UDC verify entry to gea-shopfloor-collections
  row. Surfaces UDC silent-install issue (item H pending) instead of
  letting it pass silently.

.gitignore:
- PrinterInstallerMap.exe (142 MB) excluded. Track via LFS or stage on
  PXE server only - too big for regular git history. Untouched on disk
  so existing local copy still works.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-05-04 08:49:43 -04:00
parent 64169819b3
commit ce3fbf5a28
17 changed files with 13413 additions and 294 deletions

View File

@@ -1,40 +1,39 @@
@echo off
REM check-bios.cmd - Check and apply Dell BIOS update from WinPE x64
REM Called from startnet.cmd before imaging menu
REM Requires: Flash64W.exe (Dell 64-Bit BIOS Flash Utility) in same directory
REM
REM Exit behavior:
REM - BIOS update applied -> reboots automatically (flashes during POST)
REM - Already up to date -> returns to startnet.cmd
REM - No match / no files -> returns to startnet.cmd
REM Sets BIOS_STATUS for startnet.cmd menu display
set BIOSDIR=%~dp0
set FLASH=%BIOSDIR%Flash64W.exe
set MANIFEST=%BIOSDIR%models.txt
set "BIOSDIR=%~dp0"
set "FLASH=%BIOSDIR%Flash64W.exe"
set "MANIFEST=%BIOSDIR%models.txt"
if not exist "%FLASH%" (
echo Flash64W.exe not found, skipping BIOS check.
exit /b 0
)
if exist "%FLASH%" goto :flash_ok
echo Flash64W.exe not found, skipping BIOS check.
set "BIOS_STATUS=Skipped (Flash64W.exe missing)"
exit /b 0
if not exist "%MANIFEST%" (
echo models.txt not found, skipping BIOS check.
exit /b 0
)
:flash_ok
if exist "%MANIFEST%" goto :manifest_ok
echo models.txt not found, skipping BIOS check.
set "BIOS_STATUS=Skipped (models.txt missing)"
exit /b 0
:manifest_ok
REM --- Get system model from WMI ---
set SYSMODEL=
for /f "skip=1 tokens=*" %%M in ('wmic csproduct get name 2^>NUL') do (
if not defined SYSMODEL set "SYSMODEL=%%M"
)
REM Trim trailing whitespace
for /f "tokens=*" %%a in ("%SYSMODEL%") do set "SYSMODEL=%%a"
if "%SYSMODEL%"=="" (
echo Could not detect system model, skipping BIOS check.
exit /b 0
)
if "%SYSMODEL%"=="" goto :no_model
goto :got_model
:no_model
echo Could not detect system model, skipping BIOS check.
set "BIOS_STATUS=Skipped (model not detected)"
exit /b 0
:got_model
echo Model: %SYSMODEL%
REM --- Get current BIOS version ---
@@ -46,7 +45,6 @@ for /f "tokens=*" %%a in ("%BIOSVER%") do set "BIOSVER=%%a"
echo Current BIOS: %BIOSVER%
REM --- Read manifest and find matching BIOS file ---
REM Format: ModelSubstring|BIOSFile|Version
set BIOSFILE=
set TARGETVER=
for /f "usebackq eol=# tokens=1,2,3 delims=|" %%A in ("%MANIFEST%") do (
@@ -59,60 +57,60 @@ for /f "usebackq eol=# tokens=1,2,3 delims=|" %%A in ("%MANIFEST%") do (
)
echo No BIOS update available for this model.
set "BIOS_STATUS=%SYSMODEL% - no update in catalog"
exit /b 0
:found_bios
if not exist "%BIOSDIR%%BIOSFILE%" (
echo WARNING: %BIOSFILE% not found in BIOS folder.
exit /b 0
)
if not exist "%BIOSDIR%%BIOSFILE%" goto :bios_file_missing
goto :bios_file_ok
:bios_file_missing
echo WARNING: %BIOSFILE% not found in BIOS folder.
set "BIOS_STATUS=%SYSMODEL% - %BIOSFILE% missing"
exit /b 0
:bios_file_ok
REM --- Skip if already at target version ---
echo.%BIOSVER%| find /I "%TARGETVER%" >NUL
if not errorlevel 1 goto :already_current
REM --- Compare versions to prevent downgrade ---
REM Split current and target into major.minor.patch and compare numerically
call :compare_versions "%BIOSVER%" "%TARGETVER%"
if "%VERCMP%"=="newer" goto :already_newer
goto :do_flash
:already_current
echo BIOS is already up to date - %TARGETVER%
set "BIOS_STATUS=%SYSMODEL% v%BIOSVER% (up to date)"
exit /b 0
:already_newer
echo Current BIOS %BIOSVER% is newer than target %TARGETVER% - skipping.
set "BIOS_STATUS=%SYSMODEL% v%BIOSVER% (up to date)"
exit /b 0
:do_flash
echo Update: %BIOSVER% -^> %TARGETVER%
echo Applying BIOS update (this may take a few minutes, do not power off)...
REM --- Run Flash64W.exe from BIOS directory to avoid UNC path issues ---
REM Exit codes: 0=success, 2=reboot needed, 3=already current, 6=reboot needed
pushd "%BIOSDIR%"
Flash64W.exe /b="%BIOSFILE%" /s /f /l=X:\bios-update.log
set FLASHRC=%ERRORLEVEL%
popd
echo Flash complete (exit code %FLASHRC%).
if "%FLASHRC%"=="3" (
echo BIOS is already up to date.
exit /b 0
)
if "%FLASHRC%"=="0" (
echo BIOS update complete.
exit /b 0
)
if "%FLASHRC%"=="3" goto :already_current
if "%FLASHRC%"=="0" goto :flash_done
if "%FLASHRC%"=="2" goto :staged
if "%FLASHRC%"=="6" goto :staged
echo WARNING: Flash64W.exe returned unexpected code %FLASHRC%.
echo Check X:\bios-update.log for details.
set "BIOS_STATUS=%SYSMODEL% flash error (code %FLASHRC%)"
exit /b 0
:flash_done
echo BIOS update complete.
set "BIOS_STATUS=%SYSMODEL% updated %BIOSVER% -^> %TARGETVER%"
exit /b 0
:staged
@@ -123,31 +121,23 @@ echo It will flash during POST after the
echo post-imaging reboot.
echo ========================================
echo.
set "BIOS_STATUS=%SYSMODEL% STAGED %BIOSVER% -^> %TARGETVER% (flashes on reboot)"
exit /b 0
REM ============================================================
REM compare_versions - Compare two dotted version strings
REM Usage: call :compare_versions "current" "target"
REM Sets VERCMP=newer if current > target, older if current < target, equal if same
REM ============================================================
:compare_versions
set "VERCMP=equal"
set "_CV=%~1"
set "_TV=%~2"
REM Parse current version parts
for /f "tokens=1,2,3 delims=." %%a in ("%_CV%") do (
set /a "C1=%%a" 2>NUL
set /a "C2=%%b" 2>NUL
set /a "C3=%%c" 2>NUL
)
REM Parse target version parts
for /f "tokens=1,2,3 delims=." %%a in ("%_TV%") do (
set /a "T1=%%a" 2>NUL
set /a "T2=%%b" 2>NUL
set /a "T3=%%c" 2>NUL
)
if %C1% GTR %T1% ( set "VERCMP=newer" & goto :eof )
if %C1% LSS %T1% ( set "VERCMP=older" & goto :eof )
if %C2% GTR %T2% ( set "VERCMP=newer" & goto :eof )

View File

@@ -45,3 +45,4 @@ Precision 7865 Tower|Precision_7865_1.6.1.exe|1.6.1
Precision 7875 Tower|Precision_7875_SHP_02.07.03.exe|2.7.3
Rugged 14 RB14250|Dell_Pro_Rugged_RB14250_RA13250_1.13.1.exe|1.13.1
Tower Plus 7020|OptiPlex_7020_1.22.1_SEMB.exe|1.22.1
Tower Plus QBT1250|Dell_Pro_QBT1250_QBS1250_QBM1250_QCT1250_QCS1250_QCM1250_SEMB_1.12.2.exe|1.12.2

View File

@@ -0,0 +1,89 @@
# 09-Install-PrinterInstallerMap.ps1 - imaging-time placement of the
# PrinterInstallerMap.exe tool plus a Public Desktop shortcut.
# Runs for every shopfloor PC type (Standard, CMM, Keyence, Display,
# etc.) so a tech can launch the printer installer at any bay without
# going through Edge. Numbered 09 to run after 06-OrganizeDesktop
# (which creates Shopfloor Tools folder) and 08-EdgeDefaultBrowser.
#
# Why this exists: the legacy printer-deploy flow was a web map
# (printerinstallermap.asp) that handed users a per-selection .bat to
# download and run. New Edge policy blocks both .bat downloads and
# unsigned .exe runs. PrinterInstallerMap is a forked installer with the
# map UX baked into a custom Inno wizard page; baking the EXE onto the
# image at PXE time means it never goes through a browser (no MOTW, no
# SmartScreen prompt). UAC still fires because PrivilegesRequired=admin
# and supportuser creds satisfy it - that is the only remaining gate.
#
# Layout this script produces on a Standard PC:
# C:\Tools\PrinterInstallerMap\PrinterInstallerMap.exe
# C:\Users\Public\Desktop\Shopfloor Tools\Printer Installer.lnk
#
# The Shopfloor Tools desktop folder is also created/maintained by
# 06-OrganizeDesktop.ps1; this script tolerates either order (it creates
# the folder if absent so it can run before 06).
#
# Idempotent: re-imaging or re-running the script overwrites the EXE and
# rewrites the shortcut. Drop-in newer PrinterInstallerMap.exe alongside
# this script in the imaging payload, re-image the PC, the desktop link
# now points to the new build.
#
# Source EXE expected at $PSScriptRoot\PrinterInstallerMap.exe (placed
# next to this script when the imaging payload lands). Until the Inno
# compile + sign+ship step is wired in (see ../../docs/ + the inno repo
# feature/printer-map branch), this script no-ops with a warning if the
# EXE is missing - re-image will pick it up once the build artifact is
# dropped in.
$ErrorActionPreference = 'Continue'
$src = Join-Path $PSScriptRoot 'PrinterInstallerMap.exe'
$installDir = 'C:\Tools\PrinterInstallerMap'
$installExe = Join-Path $installDir 'PrinterInstallerMap.exe'
$publicDesktop = 'C:\Users\Public\Desktop'
$shopfloorTools = Join-Path $publicDesktop 'Shopfloor Tools'
$shortcutPath = Join-Path $shopfloorTools 'Printer Installer.lnk'
if (-not (Test-Path -LiteralPath $src)) {
Write-Warning "Install-PrinterInstallerMap: source EXE not found at $src - skipping. Re-image after the build artifact is dropped alongside this script."
exit 0
}
# Stage EXE
if (-not (Test-Path -LiteralPath $installDir)) {
New-Item -Path $installDir -ItemType Directory -Force | Out-Null
}
try {
Copy-Item -LiteralPath $src -Destination $installExe -Force -ErrorAction Stop
Write-Host "Install-PrinterInstallerMap: staged EXE -> $installExe"
} catch {
Write-Error "Install-PrinterInstallerMap: failed to copy EXE: $_"
exit 1
}
# Ensure Shopfloor Tools folder exists. 06-OrganizeDesktop.ps1 also
# creates this; we create it here so this script does not depend on
# 06 having run first.
if (-not (Test-Path -LiteralPath $shopfloorTools)) {
try {
New-Item -Path $shopfloorTools -ItemType Directory -Force | Out-Null
} catch {
Write-Warning "Install-PrinterInstallerMap: could not create $shopfloorTools : $_"
}
}
# Create / overwrite shortcut
try {
$wsh = New-Object -ComObject WScript.Shell
$lnk = $wsh.CreateShortcut($shortcutPath)
$lnk.TargetPath = $installExe
$lnk.IconLocation = "$installExe,0"
$lnk.WorkingDirectory = $installDir
$lnk.Description = 'Install network printers from the WJ site map'
$lnk.Save()
Write-Host "Install-PrinterInstallerMap: shortcut -> $shortcutPath"
} catch {
Write-Error "Install-PrinterInstallerMap: failed to create shortcut: $_"
exit 1
}
exit 0

View File

@@ -60,6 +60,18 @@ echo.
PowerShell.exe -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT%"
set RC=%errorLevel%
REM On success, write a marker that Monitor-IntuneProgress.ps1 (Phase 6 /
REM Get-LockdownState) recognizes as authoritative. Manual lockdown via
REM sfld_autologon.ps1 only flips Winlogon; the Intune Remediation log
REM never gets the "Autologon set for ShopFloor" line because Detection
REM now passes and Remediation never re-runs. The marker tells the monitor
REM to treat (Winlogon registry matches + marker present) as Complete.
if "%RC%"=="0" (
if not exist "C:\Enrollment" mkdir "C:\Enrollment"
> "C:\Enrollment\force-lockdown-applied.txt" echo %DATE% %TIME%
echo Marker written: C:\Enrollment\force-lockdown-applied.txt
)
echo.
echo ============================================================
echo Lockdown script exit code: %RC%

View File

@@ -0,0 +1,12 @@
REM 1) Check if we are running elevated (admin)
net session >nul 2>&1
if %errorlevel% neq 0 (
echo Requesting elevation...
powershell -NoProfile -ExecutionPolicy Bypass ^
-Command "Start-Process -FilePath '%~f0' -Verb RunAs"
goto :EOF
)
"C:\Program Files\PowerShell\7\pwsh.exe" -NoProfile -ExecutionPolicy Bypass -File "C:\Program Files\Sysinternals\sfld_autologon.ps1"

View File

@@ -303,34 +303,91 @@ function Get-LockdownState {
# Autologon_Remediation.log - "Autologon set for ShopFloor user ..."
# Autologon_Detection.log - "... matches the expected value: 1"
#
# These are the TRUE end-of-chain signals. The DefaultUserName flip and
# admin rename that we previously checked land at PPKG time (too early);
# the IME logs only appear after Intune enrollment + category + DSC +
# Remediation cycle, which is the actual lockdown completion.
# IME logs are append-only - they never rotate or clear. Earlier logic
# used `-match` against the whole raw file, which stayed true forever
# once success was ever recorded, even if the policy later changed and
# the current state drifted. The fix below:
# (a) Walks lines from END to find the MOST RECENT outcome per log
# (latest attempt wins, stale successes are ignored)
# (b) Cross-checks Winlogon registry for ground truth - catches the
# case where IME logs say success but the reg state got rolled
# back. All three must agree for Complete to be true.
$imeLogs = 'C:\ProgramData\Microsoft\IntuneManagementExtension\Logs'
# --- Remediation: scan from tail for the LATEST result line
$remLog = Join-Path $imeLogs 'Autologon_Remediation.log'
$remDone = $false
if (Test-Path $remLog) {
try {
$content = Get-Content $remLog -Raw -ErrorAction Stop
$remDone = ($content -match 'Autologon set for ShopFloor')
$lines = Get-Content $remLog -ErrorAction Stop
for ($i = $lines.Count - 1; $i -ge 0; $i--) {
$ln = $lines[$i]
if ($ln -match 'Autologon set for ShopFloor') {
$remDone = $true
break
}
if ($ln -match 'Autologon could not be set|Remediation failed|FATAL|Exception') {
$remDone = $false
break
}
}
} catch {}
}
# --- Detection: scan from tail for the LATEST "matches the expected value: <N>"
$detLog = Join-Path $imeLogs 'Autologon_Detection.log'
$detDone = $false
if (Test-Path $detLog) {
try {
$content = Get-Content $detLog -Raw -ErrorAction Stop
$detDone = ($content -match 'matches the expected value:\s*1')
$lines = Get-Content $detLog -ErrorAction Stop
for ($i = $lines.Count - 1; $i -ge 0; $i--) {
if ($lines[$i] -match 'matches the expected value:\s*(\d+)') {
$detDone = ($matches[1] -eq '1')
break
}
}
} catch {}
}
# --- Ground truth: does Winlogon actually reflect ShopFloor autologon?
# This catches the case where IME logs report historical success but the
# reg state has since been changed back (manual tech intervention, policy
# rollback, GPO override, etc).
$registryMatches = $false
try {
$wl = Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon' -ErrorAction Stop
$registryMatches = (
[string]$wl.AutoAdminLogon -eq '1' -and
$wl.DefaultUserName -and
$wl.DefaultUserName -like 'ShopFloor*'
)
} catch {}
# --- Manual override marker written by Force-Lockdown.bat.
# When a tech runs sfld_autologon.ps1 directly (vendor-documented escape
# hatch when the Intune push hasn't applied within ~30 minutes), only
# Winlogon flips - the IME Remediation log never gets a new success
# entry because Detection now passes and Remediation isn't invoked.
# Without this marker, the strict (rem AND det AND reg) gate stays
# false forever even though the device is fully locked down.
$forceMarker = 'C:\Enrollment\force-lockdown-applied.txt'
$forceApplied = (Test-Path -LiteralPath $forceMarker)
return @{
RemediationApplied = $remDone
DetectionPassed = $detDone
Complete = ($remDone -and $detDone)
RegistryMatches = $registryMatches
ForceApplied = $forceApplied
# Two paths to Complete:
# 1. Normal Intune-driven: all three signals present (closes the
# append-only-log false-positive AND rollback-without-log case).
# 2. Manual escape hatch: marker file + ground-truth registry
# match. Marker is the audit-trail substitute for the missing
# Remediation log entry; registry is still the gate.
Complete = (
($remDone -and $detDone -and $registryMatches) -or
($forceApplied -and $registryMatches)
)
}
}
@@ -580,11 +637,21 @@ function Format-Snapshot {
@{ Ok = $Snap.Phase1.PoliciesArriving; Failed = $false }
)
# Phase 6 / Lockdown (shared by both flows, rendered last)
$p6Status = Get-PhaseStatus @(
@{ Ok = $Snap.Phase6.RemediationApplied; Failed = $false },
@{ Ok = $Snap.Phase6.DetectionPassed; Failed = $false }
)
# Phase 6 / Lockdown (shared by both flows, rendered last).
# RegistryMatches is the ground-truth check - if it flips to false after a
# historical success, something rolled back Winlogon and lockdown has drifted.
# If the manual Force-Lockdown marker is present, treat the row as COMPLETE
# to match the Get-LockdownState gate (avoids the table reading IN PROGRESS
# while the script is about to advance to setup-complete on this same tick).
if ($Snap.Phase6.Complete) {
$p6Status = 'COMPLETE'
} else {
$p6Status = Get-PhaseStatus @(
@{ Ok = $Snap.Phase6.RemediationApplied; Failed = $false },
@{ Ok = $Snap.Phase6.DetectionPassed; Failed = $false },
@{ Ok = $Snap.Phase6.RegistryMatches; Failed = $false }
)
}
if (-not $skipDsc) {
# ---- Standard / CMM / etc. (DSC flow) ----
@@ -600,24 +667,16 @@ function Format-Snapshot {
@{ Ok = $Snap.Phase3.DeployComplete; Failed = $false },
@{ Ok = $Snap.Phase3.InstallComplete; Failed = $false }
)
$p4HasFailed = $false; $p4AllDone = $true; $p4AnyStarted = $false
$p4HasScripts = ($Snap.Phase4 -and $Snap.Phase4.Count -gt 0)
if ($p4HasScripts) {
foreach ($s in $Snap.Phase4) {
if ($s.Status -eq 'failed') { $p4HasFailed = $true }
if ($s.Status -ne 'done') { $p4AllDone = $false }
if ($s.Status -ne 'pending') { $p4AnyStarted = $true }
}
} else {
# No scripts discovered. If DSC install is already complete,
# there are simply no custom scripts for this image type --
# that's COMPLETE, not WAITING.
$p4AllDone = $Snap.Phase3.InstallComplete
}
$p4Status = if ($p4HasFailed) { 'FAILED' } elseif ($p4AllDone) { 'COMPLETE' } elseif ($p4AnyStarted) { 'IN PROGRESS' } else { 'WAITING' }
# Phase 4 (per-script Application Install) was retired 2026-04-28:
# apps now deploy via the GE-Enforce manifest engine off
# \\tsgwp00525\...\machineapps\, gated on AESFMA WiFi + S: mount
# which both happen post-reboot. The monitor's responsibility ends
# at "DSC bootstrap + creds + lockdown landed, time to reboot."
# The manifest engine takes over from there on the next ShopFloor
# logon.
$p5Done = ($Snap.Phase5.ConsumeCredsTask -and $Snap.Phase5.CredsPopulated)
$p5Status = Get-PhaseStatus @(
$p4Done = ($Snap.Phase5.ConsumeCredsTask -and $Snap.Phase5.CredsPopulated)
$p4Status = Get-PhaseStatus @(
@{ Ok = $Snap.Phase5.ConsumeCredsTask; Failed = $false },
@{ Ok = $Snap.Phase5.CredsPopulated; Failed = $false }
)
@@ -629,12 +688,11 @@ function Format-Snapshot {
}
Write-Host ' 2. Device Configuration ' -NoNewline; Format-StatusTag $p2Status; Write-Host ''
Write-Host ' 3. Software Deployment ' -NoNewline; Format-StatusTag $p3Status; Write-Host ''
Write-Host ' 4. Application Install ' -NoNewline; Format-StatusTag $p4Status; Write-Host ''
Write-Host ' 5. Credential Setup ' -NoNewline; Format-StatusTag $p5Status; Write-Host ''
if ($p5Done -and $p6Status -ne 'COMPLETE') {
Write-Host ' 4. Credential Setup ' -NoNewline; Format-StatusTag $p4Status; Write-Host ''
if ($p4Done -and $p6Status -ne 'COMPLETE') {
Write-Host ' >> Initiate ARTS Lockdown request' -ForegroundColor Yellow
}
Write-Host ' 6. Lockdown ' -NoNewline; Format-StatusTag $p6Status; Write-Host ''
Write-Host ' 5. Lockdown ' -NoNewline; Format-StatusTag $p6Status; Write-Host ''
} else {
# ---- Display (no DSC, no credentials) ----
# Phases: 1 Registration -> 2 Device Configuration -> 3 Lockdown
@@ -863,17 +921,21 @@ try {
Write-Host ""
Write-Host $qrText
# Final state: every phase landed. The gate is intentionally strict
# because each piece is needed for the device to function:
# - DscInstallComplete: device-config.yaml apps + custom scripts ran
# - CredsPopulated: SFLD share creds in HKLM (Machine-Enforce,
# Acrobat-Enforce, CMM-Enforce all need these)
# - LockdownComplete: kiosk policy baseline + Winlogon flipped to
# ShopFloor autologon + admin renamed
# Final state: every phase landed. Apps are no longer in the gate -
# they deploy via the GE-Enforce manifest engine post-reboot, gated
# on AESFMA WiFi + S: drive mount which only happen after we hand
# off to the ShopFloor autologon session. So this gate is now:
# - CredsPopulated: SFLD share creds in HKLM (Machine-Enforce,
# Acrobat-Enforce, CMM-Enforce, manifest
# engine all need these)
# - LockdownComplete: kiosk policy baseline + Winlogon flipped to
# ShopFloor autologon + admin renamed
# DscInstallComplete used to be required, but device-config.yaml
# is becoming a no-op as apps migrate to the manifest engine; a
# 403 on the YAML download (e.g. expired SAS token) shouldn't
# block the imaging workflow if creds + lockdown are otherwise OK.
# Display PCs skip this whole branch via $skipDsc above.
if ($snap.DscInstallComplete -and
$snap.Phase5.CredsPopulated -and
$snap.LockdownComplete) {
if ($snap.Phase5.CredsPopulated -and $snap.LockdownComplete) {
Invoke-SetupComplete
}
@@ -912,8 +974,7 @@ try {
# Intune Remediation script mid-execution and delay lockdown.
$waitingForLockdownOnly = $false
if (-not $skipDsc) {
$waitingForLockdownOnly = ($snap.DscInstallComplete -and
$snap.Phase5.CredsPopulated -and
$waitingForLockdownOnly = ($snap.Phase5.CredsPopulated -and
-not $snap.LockdownComplete)
} else {
$waitingForLockdownOnly = ($snap.Phase1.AzureAdJoined -and

View File

@@ -0,0 +1,207 @@
# Show-IntuneDeviceQR.ps1 - Standalone version of the QR-code panel from
# Monitor-IntuneProgress.ps1, extracted for use at sites that only need the
# device-ID display (no DSC monitoring, no Intune sync triggers, no reboot
# orchestration).
#
# Polls dsregcmd /status every 15s until the device reports an Azure AD
# DeviceId, then renders that ID as a half-block QR code in the console
# along with the literal text. Stays open until the user presses a key.
#
# REQUIREMENTS
# - Windows PowerShell 5.1 (or PS 7) with .NET Framework available
# - QRCoder.dll alongside this script (or at one of the fallback paths
# below). Single ~75 KB native dependency, no internet needed.
#
# USAGE
# powershell.exe -NoProfile -ExecutionPolicy Bypass -File Show-IntuneDeviceQR.ps1
#
# Or double-click via a one-line .bat:
# powershell.exe -NoProfile -ExecutionPolicy Bypass -NoExit -File "%~dp0Show-IntuneDeviceQR.ps1"
[CmdletBinding()]
param(
[int]$PollSeconds = 15,
[string]$QRCoderDllPath = ''
)
$ErrorActionPreference = 'Continue'
# ----------------------------------------------------------------------------
# Locate QRCoder.dll. Search order:
# 1. -QRCoderDllPath if explicitly passed
# 2. Next to this script ($PSScriptRoot\QRCoder.dll)
# 3. Current working directory
# 4. WJ canonical staging path (kept for cross-site portability)
# ----------------------------------------------------------------------------
function Resolve-QRCoderDll {
param([string]$Override)
$candidates = @()
if ($Override) { $candidates += $Override }
if ($PSScriptRoot) { $candidates += (Join-Path $PSScriptRoot 'QRCoder.dll') }
$candidates += (Join-Path (Get-Location) 'QRCoder.dll')
$candidates += 'C:\Enrollment\shopfloor-setup\Shopfloor\QRCoder.dll'
foreach ($c in $candidates) {
if ($c -and (Test-Path -LiteralPath $c)) { return (Resolve-Path -LiteralPath $c).Path }
}
return $null
}
# ----------------------------------------------------------------------------
# Get the AAD DeviceId from dsregcmd. Returns $null until the device is
# Azure AD joined AND dsregcmd has populated the field.
# ----------------------------------------------------------------------------
function Get-AadDeviceId {
try {
$dsreg = dsregcmd /status 2>&1
} catch {
return $null
}
$line = $dsreg | Select-String 'DeviceId' | Select-Object -First 1
if (-not $line) { return $null }
$parts = $line.ToString().Split(':')
if ($parts.Count -lt 2) { return $null }
$id = $parts[1].Trim()
if ([string]::IsNullOrWhiteSpace($id)) { return $null }
return $id
}
# ----------------------------------------------------------------------------
# Half-block QR renderer. Each output character is 1 module wide, 2 modules
# tall, using:
# U+2588 = both top and bottom modules set
# U+2580 = top only
# U+2584 = bottom only
# space = neither
# Cuts QR height in half vs full-block rendering. Adds a 4-module quiet
# zone manually since QRCoder's ModuleMatrix excludes it by default.
# ----------------------------------------------------------------------------
function Format-QrHalfBlocks {
param([string]$Payload, [string]$DllPath, [int]$IndentSpaces = 8)
Add-Type -Path $DllPath
$gen = New-Object QRCoder.QRCodeGenerator
$data = $gen.CreateQrCode($Payload, [QRCoder.QRCodeGenerator+ECCLevel]::L)
$matrix = $data.ModuleMatrix
$size = $matrix.Count
$pad = 4
$total = $size + 2 * $pad
$upper = [char]0x2580
$lower = [char]0x2584
$full = [char]0x2588
$left = ' ' * $IndentSpaces
$lines = New-Object System.Collections.Generic.List[string]
for ($y = 0; $y -lt $total; $y += 2) {
$sb = New-Object System.Text.StringBuilder
[void]$sb.Append($left)
for ($x = 0; $x -lt $total; $x++) {
$mx = $x - $pad
$my1 = $y - $pad
$my2 = $y + 1 - $pad
$top = ($my1 -ge 0 -and $my1 -lt $size -and $mx -ge 0 -and $mx -lt $size -and $matrix[$my1].Get($mx))
$bot = ($my2 -ge 0 -and $my2 -lt $size -and $mx -ge 0 -and $mx -lt $size -and $matrix[$my2].Get($mx))
if ($top -and $bot) { [void]$sb.Append($full) }
elseif ($top) { [void]$sb.Append($upper) }
elseif ($bot) { [void]$sb.Append($lower) }
else { [void]$sb.Append(' ') }
}
$lines.Add($sb.ToString())
}
return $lines
}
# ----------------------------------------------------------------------------
# Bigger console so the QR + text frame fits in one viewport.
# ----------------------------------------------------------------------------
try {
$rui = $Host.UI.RawUI
$maxH = $rui.MaxPhysicalWindowSize.Height
$targetWindow = [Math]::Min(58, [int]$maxH)
$targetBuffer = [Math]::Max($targetWindow, 200)
$bs = $rui.BufferSize
if ($bs.Height -lt $targetBuffer) { $bs.Height = $targetBuffer; $rui.BufferSize = $bs }
$ws = $rui.WindowSize
if ($ws.Height -lt $targetWindow) { $ws.Height = $targetWindow; $rui.WindowSize = $ws }
} catch {}
# ----------------------------------------------------------------------------
# Wait for QRCoder.dll
# ----------------------------------------------------------------------------
$dllPath = Resolve-QRCoderDll -Override $QRCoderDllPath
# Mark-of-the-Web: when files arrive via SMB / zip download / email,
# Windows attaches a Zone.Identifier alternate data stream that flags the
# file as "from another computer". Add-Type then refuses to load the DLL
# with "Operation is not supported" / "Could not load file or assembly".
# Unblock-File silently strips the ADS. Best-effort: errors are non-fatal
# (file may already be unblocked, or the user may not have write access).
if ($dllPath) {
try { Unblock-File -LiteralPath $dllPath -ErrorAction SilentlyContinue } catch {}
}
if ($PSCommandPath) {
try { Unblock-File -LiteralPath $PSCommandPath -ErrorAction SilentlyContinue } catch {}
}
if (-not $dllPath) {
Write-Host ""
Write-Host "ERROR: QRCoder.dll not found." -ForegroundColor Red
Write-Host "Searched (in order):"
if ($QRCoderDllPath) { Write-Host " - $QRCoderDllPath (-QRCoderDllPath)" }
if ($PSScriptRoot) { Write-Host " - $(Join-Path $PSScriptRoot 'QRCoder.dll')" }
Write-Host " - $(Join-Path (Get-Location) 'QRCoder.dll')"
Write-Host " - C:\Enrollment\shopfloor-setup\Shopfloor\QRCoder.dll"
Write-Host ""
Write-Host "Drop QRCoder.dll alongside this script and re-run." -ForegroundColor Yellow
exit 2
}
# ----------------------------------------------------------------------------
# Poll loop - retry every $PollSeconds until DeviceId appears
# ----------------------------------------------------------------------------
Write-Host ""
Write-Host " Waiting for Azure AD device registration..." -ForegroundColor Cyan
Write-Host " Polling dsregcmd every $PollSeconds s. Press Ctrl+C to abort." -ForegroundColor DarkGray
Write-Host ""
$attempt = 0
while ($true) {
$attempt++
$deviceId = Get-AadDeviceId
if ($deviceId) { break }
Write-Host (" [{0}] attempt {1,3} no DeviceId yet, retrying in {2}s..." -f (Get-Date -Format 'HH:mm:ss'), $attempt, $PollSeconds) -ForegroundColor DarkGray
Start-Sleep -Seconds $PollSeconds
}
# ----------------------------------------------------------------------------
# Render
# ----------------------------------------------------------------------------
Clear-Host
Write-Host ""
Write-Host " GE Aerospace -- Intune Device ID" -ForegroundColor Cyan
Write-Host " $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')" -ForegroundColor DarkGray
Write-Host ""
Write-Host " Device ID: $deviceId" -ForegroundColor Green
Write-Host ""
try {
$qrLines = Format-QrHalfBlocks -Payload $deviceId -DllPath $dllPath
foreach ($l in $qrLines) { Write-Host $l }
} catch {
Write-Host " (QR generation failed: $($_.Exception.Message))" -ForegroundColor Red
}
Write-Host ""
Write-Host " Scan with phone camera or Intune admin app." -ForegroundColor DarkGray
Write-Host ""
Write-Host " Press any key to close..." -ForegroundColor Yellow
try { [void][Console]::ReadKey($true) } catch { [void](Read-Host) }
exit 0

View File

@@ -29,6 +29,7 @@
{ "$ref": "common.fmsResolver" },
{ "$ref": "common.oracle" },
{ "$ref": "common.openText" },
{ "name": "UDC", "verify": { "method": "Registry", "path": "HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\UDC", "name": "DisplayVersion", "value": "1.0.34" } },
{ "name": "FMS Primary host", "verify": { "method": "Registry", "path": "HKLM:\\SOFTWARE\\WOW6432Node\\GE Aircraft Engines\\Dnc\\FMS", "name": "FMSHostPrimary", "value": "wjfms3.ae.ge.com" } },
{ "name": "FMS Secondary host", "verify": { "method": "Registry", "path": "HKLM:\\SOFTWARE\\WOW6432Node\\GE Aircraft Engines\\Dnc\\FMS", "name": "FMSHostSecondary", "value": "10.233.112.158" } },
{ "name": "eDNC bundles NTLARS", "verify": { "method": "FileVersion", "path": "C:\\Program Files (x86)\\Dnc\\bin\\DncMain.exe", "value": "6.4.5.0" } }

View File

@@ -0,0 +1,32 @@
@echo off
REM Install-eMxInfo.cmd - copy the site-specific eMxInfo.txt into both
REM DNC Program Files install paths. Run by Install-FromManifest.ps1
REM (Type=CMD) when Hash detection on the x86 path fails.
REM
REM Real install paths confirmed via 01-eDNC.ps1 (PXE preinstall) + a
REM real shopfloor install log capture:
REM C:\Program Files (x86)\DNC\Server Files\eMxInfo.txt
REM C:\Program Files\DNC\Server Files\eMxInfo.txt
REM Both required because mxTransactionDll.dll references both.
REM
REM Earlier versions wrote to C:\Program Files\eDNC\eMxInfo.txt - that
REM path doesn't exist on disk; the eDNC product creates \DNC\ (no 'e')
REM with a "Server Files" subdir. Corrected 2026-04-28.
set "SRC=%~dp0eMxInfo.txt"
if not exist "%SRC%" (
echo Install-eMxInfo: source file not found at %SRC%
exit /b 1
)
set "DST64=C:\Program Files\DNC\Server Files"
set "DST86=C:\Program Files (x86)\DNC\Server Files"
if not exist "%DST64%\" mkdir "%DST64%" 2>nul
if not exist "%DST86%\" mkdir "%DST86%" 2>nul
copy /Y "%SRC%" "%DST64%\eMxInfo.txt" >nul || exit /b 2
copy /Y "%SRC%" "%DST86%\eMxInfo.txt" >nul || exit /b 3
echo Install-eMxInfo: deployed eMxInfo.txt to both DNC\Server Files paths
exit /b 0

View File

@@ -0,0 +1,383 @@
# Restore-UDCData.ps1 - Idempotent UDC data restore for the manifest engine.
#
# Triggered by the GE Shopfloor Enforce scheduled task (runs as SYSTEM, every
# user logon + every 5 min). Standard-machine manifest entry uses
# DetectionMethod=Always so this fires every cycle; the script self-decides
# whether there's actually any work to do.
#
# CONTRACT:
# - 99% of cycles: no backup waiting -> exit 0 in ~1 second, ~5 lines of log
# - 1 cycle (the one after Backup-UDCData lands a backup for this PC's bay):
# stop UDC, copy CurrentData.json + ArchivedData/ to C:\ProgramData\UDC,
# move consumed backup to <bay>\migrated\<timestamp>\, write
# restore.manifest.json, restart UDC. After this, root is empty so the
# check returns "no backup waiting" again on subsequent cycles.
#
# DESIGNED FOR THE SWAP WORKFLOW:
# New PC gets pre-imaged with real machine number + locked down, sits in
# storage. Days/weeks later, tech runs Backup-UDCData on old PC -> backup
# lands on share. Tech swaps PCs. New PC powers on at the bay -> ShopFloor
# autologon -> manifest engine fires this script -> backup detected ->
# restored -> UDC opens with prior history intact.
#
# Replaces the placeholder->real trigger in Update-MachineNumber.ps1 for the
# pre-imaged-then-swapped case (where the trigger fired at imaging time, before
# the backup existed). Update-MachineNumber.ps1's branch still handles the
# secondary case (tech used 9999 placeholder + sets number at bay) - both
# triggers safely no-op if the other already consumed the backup.
#
# LOGGING:
# Single rotating log at C:\Logs\UDC\Restore-UDCData.log (1 MB cap, rotated
# to .old.log on overflow). Every cycle writes a header line so even the
# silent no-op path leaves a trace. Errors include full exception type,
# position, and stack trace.
[CmdletBinding()]
param(
[string]$BackupShareRoot = '\\tsgwp00525.wjs.geaerospace.net\shared\dt\shopfloor\backup\udc',
[string]$UdcDataDir = 'C:\ProgramData\UDC',
[string]$UdcExePath = 'C:\Program Files\UDC\UDC.exe',
[string]$UdcSettingsPath = 'C:\ProgramData\UDC\udc_settings.json',
[string]$Site = 'West Jefferson',
# Share can take 20-60s to become reachable from SYSTEM context after a
# cold boot or fresh logon. Retry until then before deciding "no backup".
[int]$ShareTimeoutSec = 60,
[int]$SharePollSec = 3
)
$ErrorActionPreference = 'Continue'
# -- Logging setup --------------------------------------------------------
$logDir = 'C:\Logs\UDC'
try {
if (-not (Test-Path $logDir)) { New-Item -Path $logDir -ItemType Directory -Force | Out-Null }
} catch { $logDir = $env:TEMP }
$logFile = Join-Path $logDir 'Restore-UDCData.log'
$logFileMaxBytes = 1MB
# Rotate log file if oversized (keeps one prior generation)
try {
if ((Test-Path $logFile) -and ((Get-Item $logFile).Length -gt $logFileMaxBytes)) {
$rotated = Join-Path $logDir 'Restore-UDCData.old.log'
if (Test-Path $rotated) { Remove-Item $rotated -Force -ErrorAction SilentlyContinue }
Rename-Item -Path $logFile -NewName 'Restore-UDCData.old.log' -Force -ErrorAction SilentlyContinue
}
} catch {}
function Log {
param([string]$Msg, [string]$Level = 'INFO')
$ts = Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff'
$line = "[$ts][$Level] $Msg"
try { Add-Content -LiteralPath $logFile -Value $line -ErrorAction SilentlyContinue } catch {}
Write-Host $line
}
function LogErr {
param($Err)
if (-not $Err) { return }
$exType = if ($Err.Exception) { $Err.Exception.GetType().FullName } else { '<no exception>' }
$exMsg = if ($Err.Exception) { $Err.Exception.Message } else { "$Err" }
Log " exception: $exType - $exMsg" 'ERROR'
if ($Err.InvocationInfo -and $Err.InvocationInfo.PositionMessage) {
$pos = ($Err.InvocationInfo.PositionMessage -replace "`r?`n", ' | ')
Log " at: $pos" 'ERROR'
}
if ($Err.ScriptStackTrace) {
$st = ($Err.ScriptStackTrace -replace "`r?`n", ' | ')
Log " stack: $st" 'ERROR'
}
if ($Err.Exception -and $Err.Exception.InnerException) {
Log " inner: $($Err.Exception.InnerException.Message)" 'ERROR'
}
}
Log '==============================================='
Log "Restore-UDCData starting (PID $PID)"
Log "Hostname: $env:COMPUTERNAME"
try {
$whoami = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
} catch { $whoami = '<unknown>' }
Log "User identity: $whoami"
Log "PowerShell version: $($PSVersionTable.PSVersion)"
Log "BackupShareRoot: $BackupShareRoot"
Log "UdcDataDir: $UdcDataDir"
Log "UdcSettingsPath: $UdcSettingsPath"
Log "ShareTimeoutSec: $ShareTimeoutSec SharePollSec: $SharePollSec"
# -- Resolve local machine number ----------------------------------------
if (-not (Test-Path -LiteralPath $UdcSettingsPath)) {
Log "udc_settings.json not present - UDC not installed yet, no work to do."
Log 'Exit 0.'
exit 0
}
try {
$json = Get-Content -LiteralPath $UdcSettingsPath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
$mn = $json.GeneralSettings.MachineNumber
Log "Resolved MachineNumber from udc_settings: $mn"
} catch {
Log "Failed to parse $UdcSettingsPath" 'ERROR'
LogErr $_
Log 'Exit 0.'
exit 0
}
if (-not $mn -or $mn -eq '9999' -or $mn -notmatch '^\d+$') {
Log "Machine number is placeholder/empty/non-numeric ('$mn'). Update-MachineNumber.ps1's branch will catch the placeholder->real transition. No work to do."
Log 'Exit 0.'
exit 0
}
# -- Mount share with SFLD user creds -----------------------------------
# This script runs as NT AUTHORITY\SYSTEM (manifest engine on logon, or
# scheduled task). SYSTEM authenticates to remote SMB as the COMPUTER
# ACCOUNT (DOMAIN\HOSTNAME$), not as a user. The SFLD share's ACLs grant
# top-level enumeration to authenticated computers but file-level read
# only to a specific SFLD user. Without explicit user creds, Test-Path
# on bay-level files returns False (access denied = same return as not-
# found), making the script silently log "absent" when the files in fact
# exist. Symptom: Restore-UDCData.log shows endless "no work this cycle"
# while another PC (or a user-context invocation) successfully consumes
# the backup. Fix: mount the share with explicit SFLD creds from
# HKLM:\SOFTWARE\GE\SFLD\Credentials and probe via the drive letter.
# Inline Mount-SFLDShare so this script can run from any location -
# specifically the SFLD share path \\tsgwp00525...\standard-machine\scripts\
# where GE-Enforce executes it. The original Shopfloor\lib\Restore-EDncReg.ps1
# is only present in the C:\Enrollment\ imaging-time tree, not on the share,
# so dot-sourcing its relative path would silently fail and Mount-SFLDShare
# would be undefined at call time.
function Mount-SFLDShare {
param(
[Parameter(Mandatory)][string]$SharePath,
[string]$DriveLetter = 'V:'
)
$server = ($SharePath -replace '^\\\\', '') -split '\\' | Select-Object -First 1
$basePath = 'HKLM:\SOFTWARE\GE\SFLD\Credentials'
if (-not (Test-Path $basePath)) { return $false }
$cred = $null
foreach ($entry in Get-ChildItem -Path $basePath -ErrorAction SilentlyContinue) {
$props = Get-ItemProperty -Path $entry.PSPath -ErrorAction SilentlyContinue
if (-not $props) { continue }
# Match either the value-style TargetHost (test-fixture) or the
# production-style layout where the credential subkey NAME is the host.
$hosts = @()
if ($props.TargetHost) { $hosts += $props.TargetHost }
$hosts += $entry.PSChildName
$matched = $false
foreach ($h in $hosts) {
if (-not $h) { continue }
if ($h -eq $server -or $h -like "$server.*" -or $server -like "$h.*") {
$matched = $true; break
}
}
if ($matched -and $props.Username -and $props.Password) {
$cred = $props; break
}
}
if (-not $cred) { return $false }
& net use $DriveLetter /delete /y 2>$null | Out-Null
$null = & net use $DriveLetter $SharePath /user:$($cred.Username) $($cred.Password) /persistent:no 2>&1
return ($LASTEXITCODE -eq 0)
}
Log "Mounting share with SFLD creds: $BackupShareRoot -> W:"
$shareMounted = $false
$sw = [Diagnostics.Stopwatch]::StartNew()
while ($sw.Elapsed.TotalSeconds -lt $ShareTimeoutSec) {
if (Mount-SFLDShare -SharePath $BackupShareRoot -DriveLetter 'W:') {
$shareMounted = $true
break
}
Start-Sleep -Seconds $SharePollSec
}
$sw.Stop()
if ($shareMounted) {
Log ("Share mounted as W: after {0:N1} s" -f $sw.Elapsed.TotalSeconds)
} else {
Log "Mount-SFLDShare failed after $ShareTimeoutSec s. SFLD creds may be missing in HKLM:\SOFTWARE\GE\SFLD\Credentials, or the share is unreachable. Exiting non-zero so the dispatcher logs a failure." 'ERROR'
Log 'Exit 1.'
exit 1
}
# All bay-level paths now go through W: (authenticated as SFLD user) so
# Test-Path returns the truth, not access-denied-False.
$bayDir = Join-Path 'W:\' $mn
$srcCur = Join-Path $bayDir 'CurrentData.json'
$srcArc = Join-Path $bayDir 'ArchivedData'
Log "Probing backup paths for bay $mn"
Log " bayDir: $bayDir"
$bayDirExists = Test-Path -LiteralPath $bayDir
Log " bayDir exists: $bayDirExists"
$srcCurExists = Test-Path -LiteralPath $srcCur
Log " CurrentData.json src: $(if ($srcCurExists) { 'present' } else { 'absent' }) - $srcCur"
$srcArcExists = Test-Path -LiteralPath $srcArc
Log " ArchivedData/ src: $(if ($srcArcExists) { 'present' } else { 'absent' }) - $srcArc"
if (-not $srcCurExists -and -not $srcArcExists) {
Log "No backup waiting for bay $mn (neither CurrentData.json nor ArchivedData\ at bay root) - no work to do this cycle."
& net use W: /delete /y 2>$null | Out-Null
Log 'Exit 0.'
exit 0
}
if (-not $srcCurExists) {
Log "Partial backup waiting (ArchivedData\ present, CurrentData.json absent). Will restore ArchivedData\ only. Source PC may have had no live UDC session at backup time, or backup partially failed." 'WARN'
}
if (-not $srcArcExists) {
Log "Partial backup waiting (CurrentData.json present, ArchivedData\ absent). Will restore CurrentData.json only." 'WARN'
}
# -- We have a backup. Restore. ------------------------------------------
Log "Backup waiting at $bayDir - proceeding with restore"
# Stop UDC.exe so CurrentData.json isn't locked
$udcProcs = @(Get-Process UDC -ErrorAction SilentlyContinue)
Log "UDC processes currently running: $($udcProcs.Count)"
foreach ($p in $udcProcs) {
try {
Log " stopping UDC.exe PID $($p.Id)"
$p.Kill()
$p.WaitForExit(5000) | Out-Null
Log " stopped"
} catch {
Log " could not stop UDC.exe PID $($p.Id)" 'WARN'
LogErr $_
}
}
Start-Sleep -Milliseconds 500
# Ensure local UDC data dir exists
if (-not (Test-Path -LiteralPath $UdcDataDir)) {
Log "Creating local UDC data dir: $UdcDataDir"
try {
New-Item -Path $UdcDataDir -ItemType Directory -Force | Out-Null
} catch {
Log "Failed to create $UdcDataDir - cannot continue" 'ERROR'
LogErr $_
& net use W: /delete /y 2>$null | Out-Null
Log 'Exit 1.'
exit 1
}
}
$localCur = Join-Path $UdcDataDir 'CurrentData.json'
$localArc = Join-Path $UdcDataDir 'ArchivedData'
# Copy CurrentData.json (only if present at source)
$copiedCur = $false
if ($srcCurExists) {
Log "Copying CurrentData.json"
Log " src: $srcCur"
Log " dst: $localCur"
try {
Copy-Item -LiteralPath $srcCur -Destination $localCur -Force -ErrorAction Stop
$copiedCur = $true
$sz = (Get-Item -LiteralPath $localCur).Length
Log " OK ($sz bytes)"
} catch {
Log " FAILED" 'ERROR'
LogErr $_
}
} else {
Log "CurrentData.json not present in backup - skipping that copy step"
}
# Copy ArchivedData/
$copiedArc = $false
$arcFiles = 0
$arcBytes = 0
if ($srcArcExists) {
Log "Copying ArchivedData/"
Log " src: $srcArc"
Log " dst: $localArc"
try {
if (Test-Path -LiteralPath $localArc) {
Log " removing existing $localArc"
Remove-Item -LiteralPath $localArc -Recurse -Force -ErrorAction SilentlyContinue
}
Copy-Item -LiteralPath $srcArc -Destination $localArc -Recurse -Force -ErrorAction Stop
$arcItems = Get-ChildItem -LiteralPath $localArc -Recurse -File -ErrorAction SilentlyContinue
$arcFiles = @($arcItems).Count
$arcBytes = ($arcItems | Measure-Object Length -Sum).Sum
$copiedArc = $true
Log " OK ($arcFiles files, $arcBytes bytes)"
} catch {
Log " FAILED" 'ERROR'
LogErr $_
}
} else {
Log "ArchivedData/ not present in backup - skipping that copy step"
}
# One-shot consumption: only consume when every present source has been
# successfully copied. If a source was absent we don't fault on it; if a
# source was present but copy failed, we leave the live backup for retry.
# Must have copied at least one thing to consume.
$consumeOk = (($copiedCur -or -not $srcCurExists) -and `
($copiedArc -or -not $srcArcExists) -and `
($copiedCur -or $copiedArc))
Log "consumeOk=$consumeOk (copiedCur=$copiedCur, copiedArc=$copiedArc, srcCurExists=$srcCurExists, srcArcExists=$srcArcExists)"
if ($consumeOk) {
try {
$stamp = Get-Date -Format 'yyyy-MM-ddTHH-mm-ssZ'
$migDir = Join-Path $bayDir 'migrated'
$migStamp = Join-Path $migDir $stamp
Log "Moving consumed backup to $migStamp"
if (-not (Test-Path -LiteralPath $migDir)) { New-Item -ItemType Directory -Path $migDir -Force | Out-Null }
if (-not (Test-Path -LiteralPath $migStamp)) { New-Item -ItemType Directory -Path $migStamp -Force | Out-Null }
if (Test-Path -LiteralPath $srcCur) {
Move-Item -LiteralPath $srcCur -Destination (Join-Path $migStamp 'CurrentData.json') -Force -ErrorAction Stop
Log " moved CurrentData.json"
}
if (Test-Path -LiteralPath $srcArc) {
Move-Item -LiteralPath $srcArc -Destination (Join-Path $migStamp 'ArchivedData') -Force -ErrorAction Stop
Log " moved ArchivedData/"
}
$bakManifest = Join-Path $bayDir 'backup.manifest.json'
if (Test-Path -LiteralPath $bakManifest) {
Move-Item -LiteralPath $bakManifest -Destination (Join-Path $migStamp 'backup.manifest.json') -Force -ErrorAction SilentlyContinue
Log " moved backup.manifest.json"
}
$restoreManifest = [ordered]@{
RestoredAt = (Get-Date -Format 'o')
DestinationHostname = $env:COMPUTERNAME
DestinationUser = $whoami
MachineNumber = $mn
CurrentDataPresent = $copiedCur
CurrentDataBytes = if ($copiedCur) { (Get-Item -LiteralPath $localCur).Length } else { 0 }
ArchivedDataPresent = $copiedArc
ArchivedDataFiles = $arcFiles
ArchivedDataBytes = $arcBytes
RestoredVia = 'Restore-UDCData.ps1 (manifest engine, on logon)'
}
$restoreManifest | ConvertTo-Json | Set-Content -Path (Join-Path $migStamp 'restore.manifest.json') -Encoding UTF8
Log " wrote restore.manifest.json"
Log "Backup consumed -> migrated\$stamp\"
} catch {
Log "Move-to-migrated FAILED (data IS restored locally; live backup remains, next cycle will retry consumption)" 'ERROR'
LogErr $_
}
} else {
Log "Restore incomplete - leaving live backup at $bayDir for retry next cycle." 'WARN'
}
# Relaunch UDC with the current machine number args. UDC's vendor autostart in
# HKLM\Run will also fire on the next user logon, so this is belt-and-suspenders
# for the same-session case (e.g. tech is at the keyboard during the restore).
if ((Test-Path -LiteralPath $UdcExePath) -and ($copiedCur -or $copiedArc)) {
Log "Relaunching UDC.exe: `"$Site`" -$mn"
try {
Start-Process -FilePath $UdcExePath -ArgumentList @("`"$Site`"", "-$mn")
Log " relaunched"
} catch {
Log " UDC relaunch FAILED" 'WARN'
LogErr $_
}
}
# Unmount the SFLD-creds-mounted drive so we don't leave a stale net-use entry
& net use W: /delete /y 2>$null | Out-Null
Log 'Exit 0.'
Log '==============================================='
exit 0