Shopfloor sync_intune + Set-MachineNumber hardening

Long debugging round on the shopfloor test PC with several overlapping
bugs. This commit folds all the fixes together.

sync_intune.bat
- Slim down to an elevation thunk that launches a NEW elevated PS
  window via Start-Process -Verb RunAs (with -NoExit so the window
  doesn't vanish on error). All UI now lives in the PS monitor, not
  mixed into the cmd launcher.
- Goto-based control flow. Earlier version had nested if (...) blocks
  with literal parens inside echo lines (e.g. "wrappers (Install-eDNC,
  ...etc)."); cmd parses if-blocks by counting parens character-by-
  character, so the ")" in "etc)." closed the outer block early and
  the leftover "." threw ". was unexpected at this time.", crashing
  the elevated cmd /c window before pause ran.
- Multi-location Monitor-IntuneProgress.ps1 lookup so the user's
  quick-test workflow (drop both files on the desktop) works without
  manually editing the hardcoded path. Lookup order:
    1. %~dp0lib\Monitor-IntuneProgress.ps1
    2. %~dp0Monitor-IntuneProgress.ps1
    3. C:\Users\SupportUser\Desktop\Monitor-IntuneProgress.ps1
    4. C:\Enrollment\shopfloor-setup\Shopfloor\lib\Monitor-IntuneProgress.ps1
- Prints "Launching: <path>" as its first line so you can see which
  copy it actually loaded. This caught a bug where a stale desktop
  copy was shadowing the canonical file via fallback #2.

Set-MachineNumber.bat
- Same multi-location lookup pattern. Old version used
  %~dp0Set-MachineNumber.ps1 and bombed when the bat was copied to
  the desktop without its .ps1 sibling.
- Goto-based dispatch, no nested parens, for the same parser reason.

Monitor-IntuneProgress.ps1
- Start-Transcript at the top, writing to C:\Logs\SFLD\ (falls back
  to %TEMP% if C:\Logs\SFLD isn't writable yet) with a startup banner
  including a timestamp. Every run leaves a captured trace.
- Main polling loop wrapped in try/catch/finally. Unhandled exceptions
  print a red report with type, message, position, and stack trace,
  then block on Wait-ForAnyKey so the window can't auto-close on a
  silent crash.
- Console window resize at startup via $Host.UI.RawUI.WindowSize /
  BufferSize, wrapped in try/catch (Windows Terminal ignores it, but
  classic conhost honors it).
- Clear-KeyBuffer / Read-SingleKey / Wait-ForAnyKey helpers. Drain any
  buffered keystrokes from the polling loop before each prompt so an
  accidental keypress can't satisfy a pause prematurely.
- Invoke-SetupComplete / Invoke-RebootPrompt final-state handlers.
  The REBOOT REQUIRED branch now shows a yellow 3-line header, a
  four-line explanation, and a cyan "Press Y to reboot now, or N to
  cancel:" prompt via Read-SingleKey @('Y','N'). Y triggers
  Restart-Computer -Force (with shutdown.exe fallback), N falls
  through to Wait-ForAnyKey.
- Display order: status table FIRST, QR LAST. The cursor ends below
  the QR so the viewport always follows it - keeps the QR on screen
  regardless of window height. Works on both classic conhost and
  Windows Terminal (neither reliably honors programmatic resize).
- Half-block QR renderer: walks QRCoder's ModuleMatrix directly and
  emits U+2580 / U+2584 / U+2588 / space, one output line per two
  matrix rows. Halves the rendered height vs AsciiQRCode full-block.
  Quiet zone added manually via $pad=4 since QRCoder's ModuleMatrix
  doesn't include one. Trade-off: may not be perfectly square on all
  fonts, but the user accepted that for the smaller footprint after
  multiple iterations comparing full-block vs half-block vs PNG popup.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-04-09 13:30:12 -04:00
parent cd00d6d2e1
commit c464f45f4f
3 changed files with 336 additions and 121 deletions

View File

@@ -54,6 +54,52 @@ param(
[int]$RetriggerMinutes = 3
)
# ============================================================================
# Transcript logging - writes EVERYTHING the script sees and writes to a log
# file so we can diagnose auto-close / crash issues after the fact. Stored
# under C:\Logs\SFLD\ alongside the DSC logs. If the dir doesn't exist yet
# we fall back to %TEMP% so logging never itself fails the run.
# ============================================================================
$logDir = 'C:\Logs\SFLD'
if (-not (Test-Path $logDir)) {
try { New-Item -ItemType Directory -Path $logDir -Force | Out-Null } catch { $logDir = $env:TEMP }
}
$transcriptPath = Join-Path $logDir 'sync_intune_transcript.txt'
try { Start-Transcript -Path $transcriptPath -Append -Force | Out-Null } catch {}
Write-Host ""
Write-Host "=== Monitor-IntuneProgress.ps1 starting $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') ==="
Write-Host "Transcript: $transcriptPath"
Write-Host ""
# ============================================================================
# Console resize - the QR code is ~30 lines and the 5-phase status table is
# ~25 lines. Default cmd window is 25 rows, so the QR scrolls off the top.
# Bump the window (and buffer) to ~58 rows so the whole frame fits at once.
# Wrapped in try/catch because some hosts (Windows Terminal, ISE) don't honor
# WindowSize/BufferSize changes - in that case we just live with the default.
# ============================================================================
try {
$rui = $Host.UI.RawUI
$maxH = $rui.MaxPhysicalWindowSize.Height
$targetWindow = [Math]::Min(58, [int]$maxH)
$targetBuffer = [Math]::Max($targetWindow, 200)
# Buffer must be set FIRST and must be >= window. Width must not shrink
# below current window width or SetBufferSize throws.
$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 {}
# ============================================================================
# Helpers
# ============================================================================
@@ -306,6 +352,16 @@ function Invoke-IntuneSync {
# ============================================================================
# QR code (cached as text - generate once, re-print on every redraw)
#
# Half-block renderer: each output character represents 1 QR module wide and
# 2 QR modules tall, using Unicode half-block characters:
# U+2588 ([block]) = both top and bottom modules set
# U+2580 ([upper]) = only top module set
# U+2584 ([lower]) = only bottom module set
# space = neither set
# Cuts QR height roughly in half vs AsciiQRCode full-block rendering. May
# render slightly non-square on fonts where char cell aspect isn't exactly
# 1:2, but it's half the real estate and the user accepted that trade-off.
# ============================================================================
function Build-QRCodeText {
$lines = @()
@@ -324,19 +380,50 @@ function Build-QRCodeText {
$lines += ""
$dllPath = 'C:\Enrollment\shopfloor-setup\Shopfloor\QRCoder.dll'
if (Test-Path $dllPath) {
try {
Add-Type -Path $dllPath
$gen = New-Object QRCoder.QRCodeGenerator
$data = $gen.CreateQrCode($deviceId, [QRCoder.QRCodeGenerator+ECCLevel]::L)
$ascii = New-Object QRCoder.AsciiQRCode($data)
$qr = $ascii.GetGraphic(1, [char]0x2588 + [char]0x2588, ' ')
$lines += $qr -split "`r?`n"
} catch {
$lines += "(QR code generation failed: $($_.Exception.Message))"
}
} else {
if (-not (Test-Path $dllPath)) {
$lines += "(QRCoder.dll not found at $dllPath - skipping QR code)"
return ($lines -join "`n")
}
try {
Add-Type -Path $dllPath
$gen = New-Object QRCoder.QRCodeGenerator
$data = $gen.CreateQrCode($deviceId, [QRCoder.QRCodeGenerator+ECCLevel]::L)
# Walk the module matrix. QRCoder's ModuleMatrix does NOT include a
# quiet zone by default (confirmed empirically - for a UUID payload
# at ECC L, ModuleMatrix is 29x29 for QR3 data only). We add a
# 4-module quiet zone manually via $pad below.
$matrix = $data.ModuleMatrix
$size = $matrix.Count
$pad = 4
$total = $size + 2 * $pad
$upper = [char]0x2580
$lower = [char]0x2584
$full = [char]0x2588
$left = ' ' # 8-space indent matches the old layout
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 += $sb.ToString()
}
} catch {
$lines += "(QR code generation failed: $($_.Exception.Message))"
}
return ($lines -join "`n")
}
@@ -408,46 +495,165 @@ function Format-Snapshot {
}
# ============================================================================
# Main loop
# Key buffer drain + single-keypress reader - used before final prompts so
# any keystrokes the user tapped during the polling loop don't satisfy the
# prompt prematurely. Wrapped in try/catch because [Console]::KeyAvailable
# throws in non-interactive hosts (ISE, remoting).
# ============================================================================
$qrText = Build-QRCodeText
Invoke-IntuneSync
$lastSync = Get-Date
$nextRetrigger = $lastSync.AddMinutes($RetriggerMinutes)
function Clear-KeyBuffer {
try {
while ([Console]::KeyAvailable) { [void][Console]::ReadKey($true) }
} catch {}
}
while ($true) {
$snap = Get-Snapshot
Clear-Host
Write-Host $qrText
Write-Host ""
foreach ($l in (Format-Snapshot -Snap $snap -LastSync $lastSync -NextRetrigger $nextRetrigger)) {
Write-Host $l
function Read-SingleKey {
param([string[]]$ValidKeys)
Clear-KeyBuffer
while ($true) {
try {
$k = [Console]::ReadKey($true)
$char = ([string]$k.KeyChar).ToUpper()
if ($ValidKeys -contains $char) { return $char }
} catch {
# Fallback for hosts that don't expose ReadKey - use Read-Host.
$ans = (Read-Host ("Enter one of: " + ($ValidKeys -join '/'))).Trim().ToUpper()
if ($ValidKeys -contains $ans) { return $ans }
}
}
}
# Final state: post-reboot install complete -> exit clean
if ($snap.DscInstallComplete) {
function Wait-ForAnyKey {
param([string]$Prompt = 'Press any key to close this window...')
Write-Host ""
Write-Host $Prompt
Clear-KeyBuffer
try {
[void][Console]::ReadKey($true)
} catch {
[void](Read-Host)
}
}
# ============================================================================
# Final-state handlers - called from the main loop when either the
# post-reboot install has completed, or the pre-reboot deployment is done
# and a reboot is required. Each handler draws its own final frame, prompts
# the user, then exits.
# ============================================================================
function Invoke-SetupComplete {
Write-Host ""
Write-Host "========================================" -ForegroundColor Green
Write-Host " Setup complete - no reboot needed" -ForegroundColor Green
Write-Host "========================================" -ForegroundColor Green
Write-Host ""
Write-Host "The post-reboot DSC install phase is finished. The device is ready."
Wait-ForAnyKey
exit 0
}
function Invoke-RebootPrompt {
Write-Host ""
Write-Host "========================================" -ForegroundColor Yellow
Write-Host " REBOOT REQUIRED" -ForegroundColor Yellow
Write-Host "========================================" -ForegroundColor Yellow
Write-Host ""
Write-Host "The pre-reboot deployment phase is complete. You must reboot now"
Write-Host "to start the post-reboot DSC install phase, which downloads"
Write-Host "device-config.yaml and runs the per-app wrappers: Install-eDNC,"
Write-Host "Install-UDC, Install-VCRedists, Install-OpenText, and so on."
Write-Host ""
Write-Host "Press Y to reboot now, or N to cancel: " -NoNewline -ForegroundColor Cyan
$ans = Read-SingleKey -ValidKeys @('Y', 'N')
Write-Host $ans
if ($ans -eq 'Y') {
Write-Host ""
Write-Host "All milestones reached. Setup complete." -ForegroundColor Green
Write-Host "Rebooting in 5 seconds..." -ForegroundColor Cyan
Start-Sleep -Seconds 5
try {
Restart-Computer -Force -ErrorAction Stop
} catch {
Write-Host "Restart-Computer failed: $($_.Exception.Message)" -ForegroundColor Red
Write-Host "Falling back to shutdown.exe /r /t 5..."
& shutdown.exe /r /t 5
}
exit 0
}
# Reboot check (boot-loop-safe)
$rebootState = Test-RebootState
if ($rebootState -eq 'needed') {
Write-Host ""
Write-Host "Pre-reboot deployment phase complete - REBOOT REQUIRED" -ForegroundColor Yellow
exit 2
}
# Re-trigger sync periodically
if ((Get-Date) -ge $nextRetrigger) {
Write-Host ""
Write-Host "Re-triggering Intune sync..." -ForegroundColor Cyan
Invoke-IntuneSync
$lastSync = Get-Date
$nextRetrigger = $lastSync.AddMinutes($RetriggerMinutes)
}
Start-Sleep -Seconds $PollSecs
Write-Host ""
Write-Host "Cancelled - reboot manually when ready." -ForegroundColor Yellow
Wait-ForAnyKey
exit 0
}
# ============================================================================
# Main loop
#
# Display order is intentional: status table FIRST, QR LAST. The cursor
# ends below the QR, so the viewport follows it and keeps the QR visible
# regardless of window size. This works in classic conhost and Windows
# Terminal - neither reliably honors programmatic window resize, so we
# solve it by controlling cursor position instead.
# ============================================================================
try {
$qrText = Build-QRCodeText
Invoke-IntuneSync
$lastSync = Get-Date
$nextRetrigger = $lastSync.AddMinutes($RetriggerMinutes)
while ($true) {
$snap = Get-Snapshot
Clear-Host
Write-Host "=== Monitor running - transcript: $transcriptPath ===" -ForegroundColor DarkGray
foreach ($l in (Format-Snapshot -Snap $snap -LastSync $lastSync -NextRetrigger $nextRetrigger)) {
Write-Host $l
}
Write-Host ""
Write-Host $qrText
# Final state: post-reboot install complete
if ($snap.DscInstallComplete) {
Invoke-SetupComplete
}
# Reboot check (boot-loop-safe)
$rebootState = Test-RebootState
if ($rebootState -eq 'needed') {
Invoke-RebootPrompt
}
# Re-trigger sync periodically
if ((Get-Date) -ge $nextRetrigger) {
Write-Host ""
Write-Host "Re-triggering Intune sync..." -ForegroundColor Cyan
Invoke-IntuneSync
$lastSync = Get-Date
$nextRetrigger = $lastSync.AddMinutes($RetriggerMinutes)
}
Start-Sleep -Seconds $PollSecs
}
}
catch {
# Any unhandled exception in the main loop lands here. Write the error
# into the transcript and then block on a keypress so the PS window
# doesn't auto-close before the user can read it.
Write-Host ""
Write-Host "=====================================================" -ForegroundColor Red
Write-Host " UNHANDLED ERROR in Monitor-IntuneProgress.ps1" -ForegroundColor Red
Write-Host "=====================================================" -ForegroundColor Red
Write-Host ""
Write-Host "Message : $($_.Exception.Message)" -ForegroundColor Red
Write-Host "Type : $($_.Exception.GetType().FullName)" -ForegroundColor Red
Write-Host "At : $($_.InvocationInfo.PositionMessage)" -ForegroundColor Red
Write-Host ""
Write-Host "Stack trace:" -ForegroundColor Red
Write-Host $_.ScriptStackTrace -ForegroundColor Red
Write-Host ""
Write-Host "Full transcript: $transcriptPath"
Wait-ForAnyKey
try { Stop-Transcript | Out-Null } catch {}
exit 1
}
finally {
try { Stop-Transcript | Out-Null } catch {}
}

View File

@@ -1,81 +1,58 @@
@echo off
REM sync_intune.bat - Thin launcher for Monitor-IntuneProgress.ps1
REM sync_intune.bat - Launches Monitor-IntuneProgress.ps1 in an elevated
REM PowerShell window.
REM
REM All polling, status display, sync triggering, and reboot detection lives in
REM the PowerShell monitor. This .bat just handles:
REM 1. Self-elevate to admin
REM 2. Invoke the monitor (which renders QR + 5-phase status table)
REM 3. Branch on the monitor's exit code:
REM 0 = post-reboot install complete, "done" message and exit
REM 2 = pre-reboot deployment done, prompt for reboot
REM else = error, pause so the user can read it
REM This .bat exists only because Windows doesn't give .ps1 files a
REM "double-click to run as admin" verb. It finds the monitor script,
REM then calls Start-Process -Verb RunAs to open a NEW elevated
REM PowerShell console hosting it. The cmd window that ran this .bat
REM exits immediately afterwards - all UI (QR code, status table, reboot
REM prompt) lives in the separate PS window.
REM
REM The monitor lives at C:\Enrollment\shopfloor-setup\Shopfloor\Monitor-IntuneProgress.ps1.
REM This .bat gets copied to the user's desktop by Run-ShopfloorSetup.ps1, so
REM %~dp0 doesn't necessarily point at the shopfloor-setup tree - we use the
REM absolute path to find the monitor instead.
REM Monitor lookup order:
REM 1. %~dp0lib\Monitor-IntuneProgress.ps1
REM - repo layout / canonical on-disk layout (bat in Shopfloor/, .ps1 in Shopfloor/lib/)
REM 2. %~dp0Monitor-IntuneProgress.ps1
REM - both files dropped in same dir (quick-test on desktop)
REM 3. C:\Users\SupportUser\Desktop\Monitor-IntuneProgress.ps1
REM - dispatcher-dropped, if this .bat is being invoked from elsewhere
REM 4. C:\Enrollment\shopfloor-setup\Shopfloor\lib\Monitor-IntuneProgress.ps1
REM - canonical enrollment staging copy
REM
REM -NoExit on the inner PowerShell keeps the new window open after the
REM script finishes so the user can read any final output or error before
REM the window closes.
REM
REM Goto-based dispatch - no nested if blocks, no literal parens in echo
REM lines. CMD parses "if (...)" blocks by counting parens and will silently
REM eat any "(" or ")" inside an echo, so keeping the flow flat avoids that
REM class of syntax bomb entirely.
setlocal
title Intune Policy Sync
title Intune Policy Sync Launcher
set "MONITOR=%~dp0lib\Monitor-IntuneProgress.ps1"
if exist "%MONITOR%" goto :launch
set "MONITOR=%~dp0Monitor-IntuneProgress.ps1"
if exist "%MONITOR%" goto :launch
set "MONITOR=C:\Users\SupportUser\Desktop\Monitor-IntuneProgress.ps1"
if exist "%MONITOR%" goto :launch
REM Monitor lives under lib\ to keep it OUT of the dispatcher's baseline scan.
REM Run-ShopfloorSetup.ps1 does Get-ChildItem -Filter "*.ps1" on the Shopfloor\
REM dir (non-recursive) and runs every script it finds - if Monitor-IntuneProgress
REM lived there, the dispatcher would invoke it as a baseline script and hang the
REM whole shopfloor setup forever (it's an infinite poll loop, never returns).
set "MONITOR=C:\Enrollment\shopfloor-setup\Shopfloor\lib\Monitor-IntuneProgress.ps1"
if exist "%MONITOR%" goto :launch
REM Self-elevate to administrator
net session >nul 2>&1
if errorlevel 1 (
powershell -Command "Start-Process '%~f0' -Verb RunAs"
exit /b
)
if not exist "%MONITOR%" (
echo ERROR: Monitor not found at:
echo %MONITOR%
echo.
echo Was the shopfloor-setup tree staged correctly?
pause
exit /b 1
)
powershell -NoProfile -ExecutionPolicy Bypass -File "%MONITOR%"
set "MONITOR_EXIT=%errorlevel%"
echo ERROR: Monitor-IntuneProgress.ps1 not found in any of:
echo %~dp0lib\Monitor-IntuneProgress.ps1
echo %~dp0Monitor-IntuneProgress.ps1
echo C:\Users\SupportUser\Desktop\Monitor-IntuneProgress.ps1
echo C:\Enrollment\shopfloor-setup\Shopfloor\lib\Monitor-IntuneProgress.ps1
echo.
if "%MONITOR_EXIT%"=="0" (
echo ========================================
echo Setup complete - no reboot needed
echo ========================================
echo.
echo The post-reboot DSC install phase is finished. The device is ready.
echo.
pause
exit /b 0
)
if "%MONITOR_EXIT%"=="2" (
echo ========================================
echo REBOOT REQUIRED
echo ========================================
echo.
echo The pre-reboot deployment phase is complete. You must reboot now to
echo start the post-reboot DSC install phase, which downloads device-config.yaml
echo and runs the per-app wrappers (Install-eDNC, Install-UDC, Install-VCRedists,
echo Install-OpenText, etc).
echo.
choice /c YN /m "Reboot now"
if errorlevel 2 (
echo Cancelled - reboot manually when ready.
pause
exit /b 0
)
shutdown /r /t 5
exit /b 0
)
echo ERROR: Monitor exited with code %MONITOR_EXIT%
pause
exit /b %MONITOR_EXIT%
exit /b 1
:launch
echo Launching: %MONITOR%
powershell -NoProfile -Command "Start-Process powershell.exe -Verb RunAs -ArgumentList '-NoProfile','-NoExit','-ExecutionPolicy','Bypass','-File','%MONITOR%'"
exit /b