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 {}
}