Files
pxe-server/playbook/shopfloor-setup/Shopfloor/06-OrganizeDesktop.ps1
cproudlock cb2a9d48a1 Shopfloor: Configure-PC tool, machine-number logon prompt, execution order fixes
New tools:

Configure-PC.bat/.ps1 - Interactive desktop tool for SupportUser to
configure a shopfloor PC after imaging. Two sections:
  1. Machine number: if UDC/eDNC are still at placeholder 9999, prompt
     to set the real number right now (updates UDC JSON + eDNC registry,
     restarts UDC.exe with new args).
  2. Auto-startup toggle: pick which apps start at user logon from a
     numbered list (UDC, eDNC, Defect Tracker, WJ Shopfloor, Plant Apps).
     Creates/removes .lnk files in AllUsers Startup folder. Toggle UI
     shows [ON]/[  ] state, safe to re-run anytime. Plant Apps URL
     resolved from .url file at runtime with hardcoded fallback to
     https://mes-wjefferson.apps.lr.geaerospace.net/run/...
  3. Item 6 in the toggle list: register/unregister a "Check Machine
     Number" logon task for standard (non-admin) users. When enabled,
     the task fires at every logon, checks for 9999, pops an InputBox
     if found, updates both apps, then unregisters itself on success.

Check-MachineNumber.ps1 - The logon task script. Runs as the logged-in
user (needs GUI for InputBox), not SYSTEM. Writing to ProgramData + HKLM
is possible because 02-MachineNumberACLs.ps1 pre-grants BUILTIN\Users
write access on the two specific targets during imaging.

02-MachineNumberACLs.ps1 - Standard type-specific script (runs after
01-eDNC.ps1). Opens C:\ProgramData\UDC\udc_settings.json for Users:Modify
and HKLM:\...\GE Aircraft Engines\DNC\General for Users:SetValue. Narrow
scope, not blanket admin.

Execution order fixes in Run-ShopfloorSetup.ps1:

The dispatcher now has two lists: $skipInBaseline (scripts NOT run in the
alphabetical baseline loop) and $runAfterTypeSpecific (scripts run
explicitly after type-specific scripts complete). This fixes the bug where
06/07 ran before 01-eDNC.ps1 installed DnC, so eDNC/NTLARS shortcuts were
silently skipped.

New execution order:
  Baseline: 00-PreInstall, 04-NetworkAndWinRM (skipping 05-08 + tools)
  Type-specific: 01-eDNC, 02-MachineNumberACLs
  Finalization: 06-OrganizeDesktop, 07-TaskbarLayout

06 internally calls 05 (Office shortcuts, Phase 0) and 08 (Edge config,
Phase 4) as sub-phases, so they also benefit from running late. Office
isn't installed until after the first reboot (ppkg streams C2R), so 05
no-ops at imaging time but succeeds when 06's SYSTEM logon task re-runs
it on the second boot. 08 resolves startup-tab URLs from .url files
delivered by DSC (even later); same self-heal via the logon task.

Other fixes in this commit:

- OpenText Setup-OpenText.ps1 Step 4: exclude WJ_Office.lnk, IBM_qks.lnk,
  mmcs.lnk desktop shortcuts (matching the Step 3 .hep profile exclusion
  from the previous commit). Removes stale copies from prior installs.
- 05-OfficeShortcuts.ps1: widened Office detection to 6 path variants
  covering C2R + MSI + Office15/16, with diagnostic output on miss.
- 06-OrganizeDesktop.ps1: removed Phase 3 (desktop-root pin copies for
  eDNC/NTLARS) so shortcuts live in Shopfloor Tools only, not duplicated
  at root. Emptied $keepAtRoot. Added Phase 0 (call 05) and Phase 4
  (call 08). Lazy folder creation + empty-folder cleanup. Scheduled task
  now runs as SYSTEM (was BUILTIN\Users with Limited which failed the
  admin check). Added NTLARS to 07's taskbar pin list.
- 08-EdgeDefaultBrowser.ps1: Plant Apps URL fallback hardcoded from
  device-config.yaml.
- All new scripts have Start-Transcript logging to C:\Logs\SFLD\ with
  timestamps and running-as identity.
- Run-ShopfloorSetup.ps1: Start-Transcript + Stop-Transcript wrapping
  entire dispatcher run, writes to C:\Logs\SFLD\shopfloor-setup.log.
  Configure-PC.bat added to SupportUser desktop copy list.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 08:44:28 -04:00

479 lines
19 KiB
PowerShell

# 06-OrganizeDesktop.ps1 - Single source of truth for the Public Desktop
# layout. Builds three category folders and fills them, so end users see
# a clean desktop instead of 20+ loose icons.
#
# Creates three subfolders under C:\Users\Public\Desktop:
# Office\ - Excel, Word, PowerPoint, Outlook, OneNote, etc.
# Shopfloor Tools\ - UDC, eDNC, HostExplorer, MarkZebra, PC-DMIS, Hexagon,
# WJ Shopfloor, Defect_Tracker, NTLARS, etc.
# Web Links\ - .url shortcuts (internal portals)
#
# Two phases:
# PHASE 1 (Invoke-DesktopSweep): walks everything at the desktop ROOT,
# classifies each .lnk / .url by filename / extension / .lnk target, and
# moves it into the right category folder. Unknown items are LEFT AT
# THE ROOT on purpose. eDNC.lnk and NTLARS.lnk are allowlisted to stay
# at the root - end users click them too often to bury in a folder.
#
# PHASE 2 (Add-ShopfloorToolsApps): materializes specific app shortcuts
# into Shopfloor Tools\ from known .exe paths (UDC, eDNC, NTLARS). For
# MSI-advertised shortcuts that don't have a resolvable target (WJ
# Shopfloor, Defect_Tracker), finds the existing .lnk wherever it lives
# and copies it in. This runs AFTER the sweep so it overwrites any
# stale copies the sweeper may have moved in.
#
# Also registers an "Organize Public Desktop" scheduled task that re-runs
# the sweep (phase 1) at every logon. Phase 2 doesn't re-run - the
# shortcuts it creates are stable and 06 on first run populates them.
#
# 07-TaskbarLayout.ps1 reads the result of this script and writes
# LayoutModification.xml to pin specific shortcuts to the taskbar.
$ErrorActionPreference = 'Continue'
# ============================================================================
# Admin check - writing to C:\Users\Public\Desktop\* and registering
# system-wide scheduled tasks both require elevation. Fail fast if not
# admin so we don't spam half-successful garbage across the output.
# ============================================================================
$isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
if (-not $isAdmin) {
Write-Host ""
Write-Host "ERROR: 06-OrganizeDesktop.ps1 must run as Administrator." -ForegroundColor Red
Write-Host " Re-run from an elevated PowerShell:" -ForegroundColor Red
Write-Host " Start -> type 'powershell' -> right-click -> Run as administrator" -ForegroundColor Red
Write-Host ""
exit 1
}
$publicDesktop = 'C:\Users\Public\Desktop'
$shopfloorToolsDir = Join-Path $publicDesktop 'Shopfloor Tools'
$scriptPath = $MyInvocation.MyCommand.Path
$scriptDir = Split-Path -Parent $scriptPath
# Filenames that always stay at the desktop root regardless of classification.
# Empty right now - every shortcut lives inside a category folder for a
# clean end-user desktop. Add entries here if a future shortcut needs to
# stay pinned at root.
$keepAtRoot = @()
# ============================================================================
# Phase 1: Sweep loose shortcuts at desktop root into category folders
# ============================================================================
function Invoke-DesktopSweep {
param([string]$DesktopPath)
if (-not (Test-Path -LiteralPath $DesktopPath)) {
Write-Host "Public desktop not found at $DesktopPath - skipping sweep."
return
}
# Category definitions. Order matters: first match wins. Each category
# can classify by file Extension, file Name (regex), or .lnk Target
# (regex, resolved via WScript.Shell).
$categories = [ordered]@{
'Office' = @{
Name = @(
'^(Excel|Word|PowerPoint|Outlook|OneNote|Access|Publisher|OneDrive)(\s|$)'
)
Target = @(
'\\(EXCEL|WINWORD|POWERPNT|OUTLOOK|ONENOTE|MSACCESS|MSPUB)\.EXE$',
'\\OneDrive\.exe$'
)
}
'Shopfloor Tools' = @{
Name = @(
'^UDC',
'eDNC', '\bDNC\b', 'DncMain', 'GE DNC', 'NTLARS',
'Host\s*Explorer', 'ShopFloor', 'TN3270', 'TN5250', 'HE\s*3270', 'HE\s*5250',
'OpenText',
'Defect[_\s-]?Tracker',
'MarkZebra', 'Zebra',
'PC-?DMIS',
'Hexagon', 'CLM\s*Tools',
'Blancco',
'Keyence'
)
Target = @(
'\\UDC\.exe$',
'DncMain\.exe$', 'NTLARS\.exe$', 'DNCRemote',
'HostExplorer\.exe$', 'hostex32\.exe$', 'humicon15\.exe$',
'PC-?DMIS',
'Hexagon',
'Defect_Tracker\.application$'
)
}
'Web Links' = @{
Extension = @('.url')
}
}
# Category folders are created LAZILY - only when we're about to move
# something into them - so a Display / Wax-Trace PC without Office
# doesn't get an empty Office\ folder littering the desktop. See
# Remove-EmptyCategoryFolders at the end of the script for the
# post-sweep cleanup pass that finishes the job.
# WScript.Shell for resolving .lnk targets
$shell = $null
try { $shell = New-Object -ComObject WScript.Shell } catch {}
# Only sweep files at the desktop root, never recurse into the category
# folders themselves (that would loop things back).
$items = @(Get-ChildItem -LiteralPath $DesktopPath -File -ErrorAction SilentlyContinue)
$moved = 0
$skipped = 0
foreach ($item in $items) {
# Allowlist check: never sweep items the user wants to keep visible
if ($keepAtRoot -contains $item.Name) {
Write-Host " keep: $($item.Name) (allowlisted)"
continue
}
$category = $null
# 1. Extension match (cheapest)
foreach ($cat in $categories.Keys) {
$exts = $categories[$cat].Extension
if ($exts -and ($exts -contains $item.Extension)) {
$category = $cat
break
}
}
# 2. File name regex match
if (-not $category) {
foreach ($cat in $categories.Keys) {
$pats = $categories[$cat].Name
if (-not $pats) { continue }
foreach ($p in $pats) {
if ($item.BaseName -match $p) {
$category = $cat
break
}
}
if ($category) { break }
}
}
# 3. .lnk target resolution
if (-not $category -and $item.Extension -ieq '.lnk' -and $shell) {
try {
$sc = $shell.CreateShortcut($item.FullName)
$target = $sc.TargetPath
if ($target) {
foreach ($cat in $categories.Keys) {
$pats = $categories[$cat].Target
if (-not $pats) { continue }
foreach ($p in $pats) {
if ($target -match $p) {
$category = $cat
break
}
}
if ($category) { break }
}
}
} catch {}
}
if ($category) {
$destDir = Join-Path $DesktopPath $category
if (-not (Test-Path -LiteralPath $destDir)) {
try {
New-Item -ItemType Directory -Path $destDir -Force -ErrorAction Stop | Out-Null
} catch {
Write-Warning "Failed to create category folder '$category' : $_"
continue
}
}
$destPath = Join-Path $destDir $item.Name
try {
Move-Item -LiteralPath $item.FullName -Destination $destPath -Force -ErrorAction Stop
Write-Host " moved: $($item.Name) -> $category\"
$moved++
} catch {
Write-Warning "Failed to move $($item.Name) : $_"
}
} else {
$skipped++
}
}
if ($shell) {
try { [System.Runtime.InteropServices.Marshal]::ReleaseComObject($shell) | Out-Null } catch {}
}
Write-Host "Sweep complete: $moved moved, $skipped unclassified (left at root)."
}
# ============================================================================
# Phase 2: Materialize specific app shortcuts into Shopfloor Tools\
#
# Runs AFTER the sweep. Creates fresh .lnk files for apps we can target by
# .exe path (UDC, eDNC, NTLARS) and copies existing .lnk files for apps
# that are MSI-advertised and have no resolvable target (WJ Shopfloor,
# Defect_Tracker). Overwrites whatever the sweeper may have moved in -
# this is intentional because fresh shortcuts are more reliable than
# whatever install crud might be floating around.
#
# Each app is CONDITIONAL - if its source isn't present on this PC, the
# entry is silently skipped. Safe to run on all shopfloor PC types.
# ============================================================================
function New-ShopfloorLnk {
param(
[string]$Path,
[string]$Target,
[string]$Arguments = '',
[string]$WorkingDirectory = ''
)
$wsh = New-Object -ComObject WScript.Shell
try {
$sc = $wsh.CreateShortcut($Path)
$sc.TargetPath = $Target
if ($Arguments) { $sc.Arguments = $Arguments }
if ($WorkingDirectory) { $sc.WorkingDirectory = $WorkingDirectory }
elseif ($Target) {
$parent = Split-Path -Parent $Target
if ($parent) { $sc.WorkingDirectory = $parent }
}
$sc.IconLocation = "$Target,0"
$sc.Save()
return $true
} catch {
Write-Warning "Failed to create $Path : $_"
return $false
} finally {
try { [System.Runtime.InteropServices.Marshal]::ReleaseComObject($wsh) | Out-Null } catch {}
}
}
# Look up an existing .lnk file across the paths where installers / ppkgs
# typically drop them. Used for apps where we don't build the .lnk from
# an .exe (because the target is an MSI-advertised Darwin descriptor).
function Find-ExistingLnk {
param([string]$Filename)
$candidates = @(
(Join-Path $publicDesktop $Filename),
(Join-Path $shopfloorToolsDir $Filename),
"C:\ProgramData\Microsoft\Windows\Start Menu\Programs\$Filename",
"C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Shopfloor Tools\$Filename"
)
foreach ($c in $candidates) {
if (Test-Path -LiteralPath $c) { return $c }
}
return $null
}
function Add-ShopfloorToolsApps {
# App registry - each entry describes how to materialize one shortcut
# into Shopfloor Tools\. Folder creation is deferred until we know we
# have at least one app to put in it (lazy creation, so PC types with
# nothing installed don't get an empty Shopfloor Tools folder).
#
# Kind = 'exe' -> build a fresh .lnk from ExePath
# Kind = 'existing' -> copy an existing .lnk via Find-ExistingLnk
$apps = @(
@{ Name = 'UDC'; Kind = 'exe'; ExePath = 'C:\Program Files\UDC\UDC.exe' }
@{ Name = 'eDNC'; Kind = 'exe'; ExePath = 'C:\Program Files (x86)\Dnc\bin\DncMain.exe' }
@{ Name = 'NTLARS'; Kind = 'exe'; ExePath = 'C:\Program Files (x86)\Dnc\Common\NTLARS.exe' }
@{ Name = 'WJ Shopfloor'; Kind = 'existing'; SourceName = 'WJ Shopfloor.lnk' }
@{ Name = 'Defect_Tracker'; Kind = 'existing'; SourceName = 'Defect_Tracker.lnk' }
)
# Lazy folder creation - only create Shopfloor Tools\ the first time
# we have an app that's actually going to land in it. PC types with
# nothing installed get no empty folder.
$ensureToolsDir = {
if (-not (Test-Path -LiteralPath $shopfloorToolsDir)) {
try {
New-Item -ItemType Directory -Path $shopfloorToolsDir -Force -ErrorAction Stop | Out-Null
return $true
} catch {
Write-Warning "Failed to create $shopfloorToolsDir : $_"
return $false
}
}
return $true
}
foreach ($app in $apps) {
$dest = Join-Path $shopfloorToolsDir "$($app.Name).lnk"
switch ($app.Kind) {
'exe' {
if (Test-Path -LiteralPath $app.ExePath) {
if (-not (& $ensureToolsDir)) { break }
if (New-ShopfloorLnk -Path $dest -Target $app.ExePath) {
Write-Host " created: $($app.Name) -> $dest"
}
} else {
Write-Host " skip: $($app.Name) - target not installed ($($app.ExePath))" -ForegroundColor DarkGray
}
}
'existing' {
$src = Find-ExistingLnk $app.SourceName
if ($src) {
if (-not (& $ensureToolsDir)) { break }
try {
Copy-Item -LiteralPath $src -Destination $dest -Force -ErrorAction Stop
Write-Host " copied: $($app.Name) from $src"
} catch {
Write-Warning "Failed to copy $src -> $dest : $_"
}
} else {
Write-Host " skip: $($app.Name) - no source .lnk found" -ForegroundColor DarkGray
}
}
}
}
}
# ============================================================================
# Phase 3: Remove empty category folders
#
# If the sweep classified nothing into a given category (e.g. no Office
# on a Display PC, no Shopfloor Tools apps on a kiosk), we don't want an
# empty folder cluttering the desktop. The scheduled-task sweep also runs
# this so categories that go empty between logons self-heal.
# ============================================================================
function Remove-EmptyCategoryFolders {
foreach ($cat in @('Office', 'Shopfloor Tools', 'Web Links')) {
$dir = Join-Path $publicDesktop $cat
if (-not (Test-Path -LiteralPath $dir)) { continue }
$contents = @(Get-ChildItem -LiteralPath $dir -Force -ErrorAction SilentlyContinue)
if ($contents.Count -eq 0) {
try {
Remove-Item -LiteralPath $dir -Force -ErrorAction Stop
Write-Host " removed empty: $cat\"
} catch {
Write-Warning "Failed to remove empty $dir : $_"
}
}
}
}
# ============================================================================
# Phase 4: Edge default browser + startup tabs (delegated to 08)
#
# 08-EdgeDefaultBrowser.ps1 resolves startup-tab URLs from .url files on
# the Public Desktop (or in Web Links\ after the sweep). The .url files
# are delivered by DSC AFTER this initial shopfloor setup, so the first
# run at imaging time won't find them (falls back to hardcoded URLs,
# Plant Apps gets skipped). By calling 08 from inside 06, every SYSTEM
# scheduled-task logon re-run of 06 also re-runs 08 — so after DSC drops
# the .url files and the next sweep files them into Web Links\, 08
# picks them up and updates the Edge policy. Self-healing.
# ============================================================================
function Invoke-EdgeDefaultBrowser {
$edgeScript = Join-Path $scriptDir '08-EdgeDefaultBrowser.ps1'
if (-not (Test-Path -LiteralPath $edgeScript)) {
Write-Host " 08-EdgeDefaultBrowser.ps1 not found - skipping"
return
}
try {
& $edgeScript
} catch {
Write-Warning "08-EdgeDefaultBrowser.ps1 failed: $_"
}
}
# ============================================================================
# Scheduled task registration (re-runs 06 at every logon as SYSTEM)
# ============================================================================
function Register-SweepScheduledTask {
param([string]$ScriptPath)
$taskName = 'Organize Public Desktop'
if (-not (Test-Path -LiteralPath $ScriptPath)) {
Write-Warning "Cannot register scheduled task - script not found at $ScriptPath"
return
}
try {
$action = New-ScheduledTaskAction `
-Execute 'powershell.exe' `
-Argument "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$ScriptPath`""
$trigger = New-ScheduledTaskTrigger -AtLogOn
# Run as SYSTEM so the script can (a) pass its admin check at the
# top, (b) write to Public Desktop / ProgramData Start Menu / the
# Default User profile without Access Denied, and (c) register the
# next iteration of itself idempotently. Earlier versions used
# -GroupId 'BUILTIN\Users' -RunLevel Limited which fired at logon
# but the script exited immediately on the non-admin check.
$principal = New-ScheduledTaskPrincipal `
-UserId 'NT AUTHORITY\SYSTEM' `
-LogonType ServiceAccount `
-RunLevel Highest
$settings = New-ScheduledTaskSettingsSet `
-AllowStartIfOnBatteries `
-DontStopIfGoingOnBatteries `
-StartWhenAvailable `
-ExecutionTimeLimit (New-TimeSpan -Minutes 5)
# -ErrorAction Stop is required because Register-ScheduledTask emits
# non-terminating errors on failure (Access Denied etc), which try/catch
# would otherwise miss and we'd falsely claim success.
Register-ScheduledTask `
-TaskName $taskName `
-Action $action `
-Trigger $trigger `
-Principal $principal `
-Settings $settings `
-Force `
-ErrorAction Stop | Out-Null
Write-Host "Scheduled task '$taskName' registered (runs at every logon)."
} catch {
Write-Warning "Failed to register scheduled task : $_"
}
}
# ============================================================================
# Main
# ============================================================================
Write-Host ""
Write-Host "=== Phase 0: create Office shortcuts (if Office is installed) ==="
# Delegated to 05-OfficeShortcuts.ps1. Office is installed via ppkg but
# doesn't finish streaming until AFTER the first reboot, so the first
# imaging-time run of 06 finds no Office and this no-ops. On the second
# logon (after reboot), Office is installed and 05 creates the shortcuts
# so Phase 1 below can sweep them into the Office\ folder.
$officeScript = Join-Path $scriptDir '05-OfficeShortcuts.ps1'
if (Test-Path -LiteralPath $officeScript) {
try { & $officeScript } catch { Write-Warning "05-OfficeShortcuts.ps1 failed: $_" }
} else {
Write-Host " 05-OfficeShortcuts.ps1 not found - skipping"
}
Write-Host ""
Write-Host "=== Phase 1: sweep loose shortcuts into category folders ==="
Invoke-DesktopSweep -DesktopPath $publicDesktop
Write-Host ""
Write-Host "=== Phase 2: populate Shopfloor Tools with app shortcuts ==="
Add-ShopfloorToolsApps
Write-Host ""
Write-Host "=== Phase 3: remove empty category folders ==="
Remove-EmptyCategoryFolders
Write-Host ""
Write-Host "=== Phase 4: Edge default browser + startup tabs ==="
Invoke-EdgeDefaultBrowser
Write-Host ""
Write-Host "=== Phase 5: register logon sweeper scheduled task ==="
Register-SweepScheduledTask -ScriptPath $scriptPath
exit 0